diff --git a/AGENTS.md b/AGENTS.md index e28648d2..dc9a9531 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 - `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 - `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 -- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`。 +- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`。 - `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` / `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]` / `codex pr-preflight [--remote]`:`prompt-lint` 在派发/steer 前 dry-run 检查 runner prompt 的 DEV 测试授权分级(`read-only`/`live-read`/`live-mutating`)且不回显 prompt;`submit --dry-run` 同时给出 MiniMax/GPT/人工路由建议、该 lint 结果和 requested/effective execution mode;真实提交成功只返回写入确认、task id、服务级 runnerPermissions 和后续查看命令,不回显 prompt;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a0653774..337e6997 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,14 +30,14 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills` 的 `skills-dir` volume。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 -- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract 和 dry-run 计划,服务骨架只提供本地 `/health`、`/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 +- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract 和 dry-run 计划,服务骨架只提供本地 `/health`、`/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。 - `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 - `gh issue read [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 只在 read/view 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`,stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 - `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard:#20 必须包含 `## 看板(OPEN)`,#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24;#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 - #20 只允许承担长期 UniDesk 指挥官 / Code Queue / CLI / infra 治理总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 虚拟硬件可信闭环等产品 issue 必须写到 `pikasTech/HWLAB`;#20 只可记录 UniDesk 侧 commander/Code Queue/CLI/infra 支撑工作。`gh issue read/view 20` 会返回 `codeQueueBoardHint`;`gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间`、`## YYYY-MM-DD HH:mm 北京时间指挥更新` 和 `### YYYY-MM-DD HH:mm CST:...` 这类简报段落,也会拒绝把 `pikasTech/HWLAB#N`、`HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20,并在 `codeQueueBoardHint` 中提示改写到每日简报 issue 或 `pikasTech/HWLAB`;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20。 -- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 +- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`;`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时。 - `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get <issueNumber> --board-issue 20` 和 `gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]` 是 #20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射固定为:`branch` -> Branch,`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert <issueNumber> --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section` 或 `--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move`。`gh issue board-row add <issueNumber> --board-issue 20 --section open|closed --row-file <file> [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move <issueNumber> --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete <issueNumber> --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha` 或 `--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。 - `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view <number|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=false`。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`gh pr preflight <number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number>` 和 `gh pr closeout <number>` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`、`policy.unideskCliMergeSupported=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 6c184e8b..def62628 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -303,11 +303,11 @@ stale-active 恢复和 `/api/scheduler/reconcile?staleMs=...` 诊断入口的 he 只有多个独立观察面同时失败,或同一关键路径在明确时间窗口内持续失败,才能把问题判为全局阻塞。否则应记录为 transient、`runner-local-observation-gap`、`external-provider-backoff` 或 `control-plane-observation-gap`,优先重试、steer 任务纠偏或拆出基础设施 follow-up;不得让业务 worker 把单次局部失败作为最终 blocker。CLI 和 runtime 必须把错误输出结构化为 `scope=runner-local|external-provider|control-plane|provider-gateway|ssh|registry|k3s|scheduler|service-proxy`、`observedAt`、`retryable`、`decision` 或 `blockingDisposition`、`healthyScopes`、`failedScopes` 和建议的交叉验证命令。当前 runner/local backend-core 容器缺失属于 runner-local observation gap;远程控制面也不可达属于 control-plane observation gap;两者都不能单独写成 active runner 数归零或 scheduler 停摆。 -ClaudeQQ 是面向用户的主动提醒通道,不是 #24 简报更新的自动转发器。指挥官只应在三类情况下自主发送 ClaudeQQ 消息:核心服务或关键执行面宕机且需要用户知情,高风险决策需要用户请示,或出现里程碑式进展值得同步。消息必须简明扼要,一次不超过 200 个中文字符,写成一段话,不使用 Markdown 语法。普通轮询、普通 issue 更新、普通 #24 简报追加、外部 token provider 正常限流、以及无用户动作要求的中间状态,不发送 ClaudeQQ。发送失败只记录到 #24 或对应 blocker issue,不回滚已经完成的 GitHub issue 更新。 +ClaudeQQ 是面向用户的主动提醒通道,不是 #24 简报更新的自动转发器。指挥官只应在三类情况下自主发送 ClaudeQQ 消息:核心服务或关键执行面宕机且需要用户知情,高风险决策需要用户请示,或出现里程碑式进展值得同步。消息必须简明扼要,一次不超过 200 个中文字符,写成一段话,不使用 Markdown 语法。普通轮询、普通 issue 更新、普通 #24 简报追加、外部 token provider 正常限流、以及无用户动作要求的中间状态,不发送 ClaudeQQ。发送失败只记录到 #24 或对应 blocker issue,不回滚已经完成的 GitHub issue 更新。高风险审批通知不得走本机 ClaudeQQ skill、powershell 或本地 server;repo-owned 路径是先用 `bun scripts/cli.ts commander approval request --action <action> --dry-run [--task-id id] [--reason text]` 生成草案,获得授权后才使用 backend-core proxy 命令 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`。 重启 Code Queue backend、重建 Code Queue backend 容器、重启 Code Queue 执行面,或对运行中 Code Queue 任务执行 interrupt/cancel 这类会改变执行状态的操作,都属于高风险干预。即使看起来是最小恢复动作,指挥官也必须先通过 ClaudeQQ 向用户上报原因、影响范围和拟执行动作,并等待用户明确同意;未获得同意前只能做只读诊断、记录 issue、更新看板和准备恢复方案。 -host Codex 指挥官正规化后仍受同一条高风险边界约束。`docs/reference/host-codex-commander.md` 中的直管微服务只能把 host Codex 保活、SSH/PTY/stdio bridge、prompt plan、trace summary、#20/#46 issue 入口和 ClaudeQQ 审批记录产品化;它不是 Code Queue runner,也不是 Code Queue 自部署通道。第一阶段 `bun scripts/cli.ts commander ...` 只允许输出 contract/dry-run 计划,不得实际重启 backend、interrupt/cancel task、读取 token 明文、打开 bridge 或发送 ClaudeQQ。 +host Codex 指挥官正规化后仍受同一条高风险边界约束。`docs/reference/host-codex-commander.md` 中的直管微服务只能把 host Codex 保活、SSH/PTY/stdio bridge、prompt plan、trace summary、#20/#46 issue 入口和 ClaudeQQ 审批记录产品化;它不是 Code Queue runner,也不是 Code Queue 自部署通道。第一阶段 `bun scripts/cli.ts commander ...` 只允许输出 contract/dry-run 计划,不得实际重启 backend、interrupt/cancel task、读取 token 明文、打开 bridge 或发送 ClaudeQQ。若 dry-run 返回 `notification-path-unavailable`,这是正式发送未开放的 blocker;指挥官应记录到 issue/#24,并使用输出里的 backend-core proxy command 作为后续授权后的唯一候选命令。 当多信号裁决显示 provider 服务器、D601 执行面或关键维护桥疑似需要人工检查时,指挥官可以在更新 #24/#40 等记录之外,通过 ClaudeQQ 额外提醒用户检查 provider 服务器状态。提醒只在首次确认、状态恶化、恢复或需要用户介入时发送,不能在每轮轮询中重复轰炸。ClaudeQQ 提醒是 best-effort:若 ClaudeQQ 本身依赖同一条故障 provider/k3sctl 链路而不可达,指挥官应把通知失败的原因写入 #24 或对应 blocker issue,并继续按轮询和恢复规则推进。 diff --git a/docs/reference/host-codex-commander.md b/docs/reference/host-codex-commander.md index c624be20..f527cfdd 100644 --- a/docs/reference/host-codex-commander.md +++ b/docs/reference/host-codex-commander.md @@ -1,15 +1,16 @@ # Host Codex Commander Skeleton -本文定义 host Codex 指挥官的本地 skeleton 阶段。仓库内正式 `commander` CLI 当前只提供 `/health`、`/api/commander/contract`、`.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官。 +本文定义 host Codex 指挥官的本地 skeleton 阶段。仓库内正式 `commander` CLI 当前只提供 `/health`、`/api/commander/contract`、`.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官。高风险 Code Queue recovery 的 ClaudeQQ 审批在本阶段只生成 200 字以内中文纯文本草案和 backend-core proxy 命令,不走本机 skill、powershell 或本地 ClaudeQQ server。 当前允许存在一个 operator-run 的高鲁棒 stdio 保活形态,用于在人工授权下让 host Codex 持续承担指挥官工作。该形态不是仓库内正式 daemon,也不替代 skeleton contract;它应被视为过渡运行方式,后续产品化仍必须回到本文件的 contract、state、approval 和安全边界。 ## 边界 - `host-codex-commander` 是独立的本地 skeleton,不是运行中的 live daemon。 -- 只允许本地文件状态、trace 摘要和审批草案预览。 +- 只允许本地文件状态、trace 摘要和审批草案预览;审批通知草案必须是 200 字以内中文纯文本,不使用 Markdown。 - 所有输出必须 redaction token/secret/URL credential。 - 不得重启或接管 Code Queue backend,不得 cancel/interrupt 运行任务,不得打印 token 明文。 +- ClaudeQQ 正式发送未在 skeleton 中实现;如后续获得授权,唯一允许路径是 UniDesk backend-core microservice proxy:`bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`。 - Host commander 观察到 `bun scripts/cli.ts codex pr-preflight --remote` 返回 `scheduler-runner-env` / `auth-missing` 时,只能把它解释为 scheduler runtime preflight surface 缺少 GitHub auth;不得据此判定当前 active runner 或 dev container 不能 push、创建 PR 或评论 PR。 - `pikasTech/unidesk#20` 只作为 UniDesk commander / Code Queue / CLI / infra governance 入口;HWLAB 用户和产品 issue 必须路由到 `pikasTech/HWLAB`,不能作为 #20 看板行维护。 @@ -77,7 +78,7 @@ host commander 不直接编辑 HWLAB 业务代码,不以本地热修绕过 HWL - health endpoint:用 `createCommanderRequestHandler` 和临时 `RuntimeConfig` 调用 `GET /health`,期望返回 `service=host-codex-commander`、`stateRoot` 和日志文件路径;禁止 `Bun.serve` 和端口监听。 - state file:只在临时目录写读 `sessions/<sessionId>.json`、`events/<sessionId>.jsonl` 和 `approvals/draft.json`,确认 session 状态和 redaction round-trip;禁止触碰真实 `.state/commander/`。 - trace summary dry-run:只喂 mock JSONL 给 `summarizeCommanderTrace`,确认 `taskId`、`sessionId`、`lastSeq`、`status`、`redactionsApplied` 和有界摘要;禁止读取 live Code Queue trace、标记已读、interrupt 或 cancel。 -- approval draft preview:只运行 `commander approval request --dry-run` 或 `buildCommanderApprovalDraft`,确认 `requiresExplicitUserApproval=true`、`claudeqq.mutation=false`、`sendImplemented=false` 和敏感信息脱敏;禁止 POST ClaudeQQ。 +- approval draft preview:只运行 `commander approval request --dry-run` 或 `buildCommanderApprovalDraft`,确认 `requiresExplicitUserApproval=true`、`claudeqq.mutation=false`、`sendImplemented=false`、`dryRunNoClaudeQqSend=true`、`notificationDraft.message` 为 200 字以内中文纯文本、`notificationPath.error=notification-path-unavailable`,并返回 backend-core `microservice proxy claudeqq /api/push/text` 命令;禁止 POST ClaudeQQ。 - SSH bridge boundary:只检查 `commander plan --dry-run` 中 `bridge.mutation=false`、`startPlan.enabled=false` 和 `safetyBoundary.phaseOneMutationAllowed=false`;禁止打开 SSH、PTY 或 stdio bridge。 轻量契约测试是: @@ -130,6 +131,15 @@ trace summary 输入 mock Code Queue trace JSONL 和可选 task summary,输出 高风险动作只生成 approval draft JSON / Markdown preview。preview 必须显示 redaction 结果,并明确 `sendImplemented=false`。 +`commander approval request --dry-run` 的 ClaudeQQ 字段必须包含: + +- `notificationDraft.message`:给用户看的 200 字以内中文纯文本审批草案,一段话,不含 Markdown。 +- `dryRunNoClaudeQqSend=true`:确认 dry-run 不发送。 +- `notificationPath.error=notification-path-unavailable`:说明当前 skeleton 未开放真实发送。 +- `notificationPath.backendCoreProxyCommand`:授权后才可执行的 repo-owned 发送命令,路径固定为 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`。 + +不得使用 `/root/.agents/skills/claudeqq`、`powershell.exe`、本地 skill server 或 9082 端口作为高风险审批通知路径。若 backend-core ClaudeQQ proxy 发送失败,失败必须结构化为 `notification-proxy-failed` / `microservice-proxy-failed`,不得打印 token,不回滚已经完成的 GitHub issue 更新;指挥官应把失败原因写入 #24 或对应 blocker issue。 + ## 进入真实运行态前 -启用 daemon、PTY/stdio bridge、SSH bridge 或 ClaudeQQ 发送路径前,必须先获得人工授权。授权必须绑定一个精确 action 和目标 session/task/service,已有 smoke/skeleton contract 通过,风险审查确认不会打印 token、不会直接 patch 数据库、不会绕过 backend 确认策略,并且已有可审计的 approval id、回滚步骤和观测步骤。 +启用 daemon、PTY/stdio bridge、SSH bridge 或 ClaudeQQ 发送路径前,必须先获得人工授权。授权必须绑定一个精确 action 和目标 session/task/service,已有 smoke/skeleton contract 通过,风险审查确认不会打印 token、不会直接 patch 数据库、不会绕过 backend 确认策略,并且已有可审计的 approval id、回滚步骤和观测步骤。ClaudeQQ 发送只能通过 backend-core `/api/microservices/claudeqq/proxy`,并设置超时和结构化失败输出。 diff --git a/scripts/claudeqq-artifact-event-contract-test.ts b/scripts/claudeqq-artifact-event-contract-test.ts index f6257827..76290301 100644 --- a/scripts/claudeqq-artifact-event-contract-test.ts +++ b/scripts/claudeqq-artifact-event-contract-test.ts @@ -21,12 +21,12 @@ function asRecord(value: unknown, path: string): JsonRecord { function asArray(value: unknown, path: string): unknown[] { assertCondition(Array.isArray(value), `${path} must be an array`); - return value; + return value as unknown[]; } function stringField(value: unknown, path: string): string { assertCondition(typeof value === "string" && value.length > 0, `${path} must be a non-empty string`); - return value; + return value as string; } function serviceById(environment: JsonRecord, id: string, path: string): JsonRecord { diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts index 86677bb0..4ea13d8b 100644 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ b/scripts/code-queue-pr-preflight-contract-test.ts @@ -656,7 +656,7 @@ async function main(): Promise<void> { }), }, }), - }), "github transient full record"); + })); const githubTransientPreflight = asRecord(githubTransientFullRecord.preflight); assertCondition(githubTransientPreflight.retryable === true, "GitHub transient full preflight should be retryable", githubTransientPreflight); assertCondition(githubTransientPreflight.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient full preflight should expose bounded commander action", githubTransientPreflight); diff --git a/scripts/host-codex-commander-approval-notification-contract-test.ts b/scripts/host-codex-commander-approval-notification-contract-test.ts new file mode 100644 index 00000000..6e0aeebe --- /dev/null +++ b/scripts/host-codex-commander-approval-notification-contract-test.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { commanderApprovalProxyFailureSummary } from "../src/components/microservices/host-codex-commander/src/approval-notification"; + +type JsonRecord = Record<string, unknown>; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): JsonRecord { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); + return value as JsonRecord; +} + +function runCli(args: string[], expectStatus: number, extraEnv: Record<string, string> = {}): JsonRecord { + const result = spawnSync(process.execPath, ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + }, + maxBuffer: 4 * 1024 * 1024, + }); + assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, { + status: result.status, + stdout: result.stdout.slice(-2000), + stderr: result.stderr.slice(-2000), + }); + assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`); + return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); +} + +function dataOf(envelope: JsonRecord): JsonRecord { + return asRecord(envelope.data, "data"); +} + +function charLength(value: string): number { + return Array.from(value).length; +} + +function assertPlainApprovalMessage(message: string): void { + assertCondition(charLength(message) <= 200, "approval notification draft must be <=200 chars", { message, chars: charLength(message) }); + assertCondition(!/[`*_#[\]|]/u.test(message), "approval notification draft must not contain Markdown syntax", { message }); + assertCondition(!message.includes("\n"), "approval notification draft must be one paragraph", { message }); +} + +const approval = dataOf(runCli([ + "commander", + "approval", + "request", + "--action", + "code-queue-task-interrupt", + "--task-id", + "stale-active-118", + "--reason", + "stale-active 恢复需要 interrupt;token=ghp_1234567890abcdef;不要使用 `local` 路径", + "--dry-run", +], 0, { + PATH: "/usr/bin:/bin", +})); + +assertCondition(approval.ok === true, "approval dry-run must succeed without local powershell", approval); +assertCondition(approval.mutation === false, "approval dry-run must be non-mutating", approval); +assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval dry-run must redact secret-like reason", approval); + +const claudeqq = asRecord(approval.claudeqq, "claudeqq"); +assertCondition(claudeqq.mutation === false, "ClaudeQQ dry-run must not mutate", claudeqq); +assertCondition(claudeqq.sendImplemented === false, "commander skeleton must not claim send implementation", claudeqq); +assertCondition(claudeqq.dryRunNoClaudeQqSend === true, "dry-run must explicitly report no ClaudeQQ send", claudeqq); + +const draft = asRecord(claudeqq.notificationDraft, "claudeqq.notificationDraft"); +assertCondition(draft.format === "plain-text", "approval draft must be plain text", draft); +assertCondition(draft.markdownAllowed === false, "approval draft must forbid Markdown", draft); +assertCondition(draft.containsMarkdownSyntax === false, "approval draft must report no Markdown syntax", draft); +assertPlainApprovalMessage(String(draft.message)); + +const path = asRecord(claudeqq.notificationPath, "claudeqq.notificationPath"); +assertCondition(path.error === "notification-path-unavailable", "dry-run must expose notification-path-unavailable blocker", path); +assertCondition(path.servicePath === "/api/microservices/claudeqq/proxy/api/push/text", "service path must be backend-core ClaudeQQ proxy", path); +assertCondition(String(path.backendCoreProxyCommand).includes("bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST"), "backend-core proxy command must be returned", path); +assertCondition(!String(path.backendCoreProxyCommand).includes("powershell"), "backend-core proxy command must not use local powershell", path); +assertCondition(!String(path.backendCoreProxyCommand).includes(".agents/skills/claudeqq"), "backend-core proxy command must not use local skill path", path); +assertCondition(path.timeoutMs === 15000, "proxy path must disclose timeout", path); + +const failure = commanderApprovalProxyFailureSummary({ + ok: false, + status: 503, + error: "microservice proxy task failed", + stderrTail: "token=ghp_1234567890abcdef powershell.exe failed", +}); +assertCondition(failure.ok === false, "proxy failure summary must be failing", failure); +assertCondition(failure.error === "notification-proxy-failed", "proxy failure summary must be structured", failure); +assertCondition(failure.degradedReason === "microservice-proxy-failed", "proxy failure degraded reason must be microservice-proxy-failed", failure); +assertCondition(asRecord(failure.rollback, "rollback").issueUpdateRolledBack === false, "proxy failure must not imply issue rollback", failure); +assertCondition(!JSON.stringify(failure).includes("ghp_1234567890abcdef"), "proxy failure summary must redact secrets", failure); + +const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-brief-notify-")); +try { + const bodyPath = join(tmp, "brief.md"); + writeFileSync(bodyPath, [ + "# 指挥简报", + "", + "## 常驻观察与长期建议", + "", + "- 保持监督。", + "", + "## 更新 2026-05-23 10:00 北京时间", + "", + "- 新增高风险审批路径 dry-run 预览。", + "", + ].join("\n"), "utf8"); + const ghDryRun = dataOf(runCli([ + "gh", + "issue", + "edit", + "24", + "--body-file", + bodyPath, + "--notify-claudeqq-brief-diff", + "--dry-run", + ], 0, { + UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL: "http://127.0.0.1:9082", + })); + const notification = asRecord(ghDryRun.commanderBriefNotification, "commanderBriefNotification"); + const ghClaudeqq = asRecord(notification.claudeqq, "commanderBriefNotification.claudeqq"); + assertCondition(ghClaudeqq.wouldSend === false, "dry-run helper must not allow non-proxy ClaudeQQ path", ghClaudeqq); + assertCondition(ghClaudeqq.blockedReason === "notification-path-unavailable", "dry-run helper must structure non-proxy blocker", ghClaudeqq); + assertCondition(String(ghClaudeqq.recommendedCommand).includes("microservice proxy claudeqq"), "dry-run helper must recommend repo-owned proxy command", ghClaudeqq); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +const hostDoc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); +const supervisionDoc = readFileSync("docs/reference/code-queue-supervision.md", "utf8"); +for (const snippet of [ + "notification-path-unavailable", + "microservice proxy claudeqq /api/push/text", + "200 字以内中文纯文本", +]) { + assertCondition(hostDoc.includes(snippet), `host commander doc missing snippet: ${snippet}`); +} +for (const snippet of [ + "不超过 200 个中文字符", + "不使用 Markdown 语法", + "发送失败只记录到 #24 或对应 blocker issue", +]) { + assertCondition(supervisionDoc.includes(snippet), `code queue supervision doc missing snippet: ${snippet}`); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "commander approval dry-run survives without local powershell or ClaudeQQ skill server", + "dry-run generates a <=200 char Chinese plain-text ClaudeQQ draft and does not send", + "dry-run exposes notification-path-unavailable plus backend-core microservice proxy command", + "microservice proxy failure summary is structured, redacted, and best-effort", + "legacy gh issue edit notify dry-run rejects non-proxy ClaudeQQ base URLs", + "reference docs state the high-risk ClaudeQQ approval notification path", + ], +}, null, 2)}\n`); diff --git a/scripts/host-codex-commander-skeleton-contract-test.ts b/scripts/host-codex-commander-skeleton-contract-test.ts index 5adc8041..9083fb4c 100644 --- a/scripts/host-codex-commander-skeleton-contract-test.ts +++ b/scripts/host-codex-commander-skeleton-contract-test.ts @@ -71,7 +71,9 @@ try { assertCondition(session.notes[0] === "<redacted>", "session notes must be redacted", session); assertCondition(readCommanderSession(runtime, "primary").state === "running", "session read must round-trip", readCommanderSession(runtime, "primary")); assertCondition(listCommanderSessions(runtime).length >= 1, "session listing must include stored session", listCommanderSessions(runtime)); - assertCondition(commanderSessionPreview(session).notes.includes("<redacted>"), "session preview must redact notes", commanderSessionPreview(session)); + const sessionPreview = asRecord(commanderSessionPreview(session), "session preview"); + const sessionPreviewNotes = Array.isArray(sessionPreview.notes) ? sessionPreview.notes.map((item) => String(item)) : []; + assertCondition(sessionPreviewNotes.includes("<redacted>"), "session preview must redact notes", sessionPreview); const trace = summarizeCommanderTrace({ taskId: "task-123", @@ -117,7 +119,7 @@ try { assertCondition(Array.isArray(sessionsBody.sessions) && sessionsBody.sessions.length >= 1, "sessions route must list sessions", sessionsBody); const traceBody = await readJson(await handler(new Request(`http://localhost/api/commander/trace-summary?taskId=task-123&traceJsonl=${encodeURIComponent(JSON.stringify({ seq: 1, status: "running", summary: "hello token=ghp_1234567890abcdef" }))}`))); - assertCondition(traceBody.ok === true && asRecord(traceBody.summary, "summary").redactionsApplied >= 1, "trace route must redact and summarize", traceBody); + assertCondition(traceBody.ok === true && Number(asRecord(traceBody.summary, "summary").redactionsApplied) >= 1, "trace route must redact and summarize", traceBody); const approvalBody = await readJson(await handler(new Request("http://localhost/api/commander/approvals", { method: "POST", diff --git a/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts b/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts index 93cd43ce..0e635afb 100644 --- a/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts +++ b/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts @@ -25,7 +25,7 @@ function asRecord(value: unknown, label: string): JsonRecord { function asArray(value: unknown, label: string): unknown[] { assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value; + return value as unknown[]; } interface ContractCase { diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index e473ffb0..1b45c9f3 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -2813,7 +2813,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti const drifts = deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) ? compareDeployJsonExecutorMirrors(deployJsonService, environment, artifactRegistryDeployJsonMirrors(effectiveOptions, spec, target)) : []; - if (drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts); + if (deployJsonService !== null && drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts); const verificationBlocked = spec.runtimeVerification === "blocked"; const livePolicy = artifactConsumerLivePolicy(spec, environment); const liveReason = artifactConsumerLiveBlockReason(spec, environment); diff --git a/scripts/src/commander.ts b/scripts/src/commander.ts index aadd07ae..ac7659c5 100644 --- a/scripts/src/commander.ts +++ b/scripts/src/commander.ts @@ -1,4 +1,6 @@ +import { buildCommanderApprovalNotificationDraft, commanderApprovalNotificationPathUnavailable } from "../../src/components/microservices/host-codex-commander/src/approval-notification"; import { commanderContract as hostCommanderContract, commanderHighRiskActions as highRiskActions } from "../../src/components/microservices/host-codex-commander/src/contract"; +import { redactText } from "../../src/components/microservices/host-codex-commander/src/redaction"; const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented."; @@ -24,24 +26,6 @@ function isHighRiskAction(value: string): value is HighRiskAction { return highRiskActions.some((action) => action === value); } -function redactText(value: string): { text: string; redactionsApplied: number } { - let redactionsApplied = 0; - const patterns = [ - /\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/g, - /\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/gi, - /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi, - /https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/gi, - ]; - let text = value; - for (const pattern of patterns) { - text = text.replace(pattern, () => { - redactionsApplied += 1; - return "<redacted>"; - }); - } - return { text, redactionsApplied }; -} - function commanderHelp(): Record<string, unknown> { return { command: "commander", @@ -59,7 +43,7 @@ function commanderHelp(): Record<string, unknown> { } export function commanderContract(): Record<string, unknown> { - return hostCommanderContract(); + return hostCommanderContract() as unknown as Record<string, unknown>; } function safetyBoundary(): Record<string, unknown> { @@ -264,6 +248,9 @@ function commanderPlan(args: string[]): Record<string, unknown> { claudeqqApproval: { mutation: false, commandShape: "bun scripts/cli.ts commander approval request --action <action> --dry-run", + dryRunDraft: "Chinese plain-text ClaudeQQ approval draft, <=200 chars, no Markdown", + liveSendBlockedBy: "notification-path-unavailable", + backendCoreProxyOnly: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw", highRiskActions, }, safetyBoundary: safetyBoundary(), @@ -342,6 +329,9 @@ function approvalDraftValidation(): Record<string, unknown> { "requiresExplicitUserApproval=true", "claudeqq.mutation=false", "claudeqq.sendImplemented=false", + "claudeqq.notificationDraft.message is <=200 Chinese chars and plain text", + "claudeqq.notificationPath.error=notification-path-unavailable", + "backend-core microservice proxy command is returned instead of a local skill/powershell command", "reason and messageTemplate are redacted", ], noRuntimeSideEffects: [ @@ -423,7 +413,7 @@ function commanderSmoke(args: string[]): Record<string, unknown> { "operator explicitly names the exact live action and target session/task/service", "current source-contract smoke and skeleton contract tests are green", "risk review confirms no token output, no direct database patch, and no backend restart bypass", - "ClaudeQQ approval draft is reviewed, sent by an authorized future path, and matched to an explicit approval id", + "ClaudeQQ approval draft is reviewed, any authorized send uses backend-core /api/microservices/claudeqq/proxy, and the reply is matched to an explicit approval id", "rollback and observation steps are written before enabling any daemon or bridge", ], }; @@ -458,6 +448,8 @@ function commanderApprovalRequest(args: string[]): Record<string, unknown> { const rawReason = optionValue(args, "--reason") ?? "operator-supplied reason required before live execution"; const reason = redactText(rawReason); const taskId = optionValue(args, "--task-id") ?? null; + const notificationDraft = buildCommanderApprovalNotificationDraft({ action, taskId, reason: rawReason }); + const notificationPath = commanderApprovalNotificationPathUnavailable(notificationDraft.message); return { ok: true, phase: "source-contract", @@ -468,12 +460,18 @@ function commanderApprovalRequest(args: string[]): Record<string, unknown> { reason: reason.text, redactionsApplied: reason.redactionsApplied, requiresExplicitUserApproval: true, + notificationDraft, claudeqq: { mutation: false, - endpointShape: "POST /api/microservices/claudeqq/proxy/api/push/text", + endpointShape: `POST ${notificationPath.servicePath}`, + fallbackEndpointShape: `POST ${notificationPath.fallbackServicePath}`, target: "configured primary user private chat", - messageTemplate: `Approval required for ${action}. Reason: ${reason.text}. Reply with explicit approval id before execution.`, + messageTemplate: notificationDraft.message, sendImplemented: false, + dryRunNoClaudeQqSend: true, + notificationDraft, + notificationPath, + noLocalSkillFallback: true, }, approvalRecordShape: { id: "commander-approval-<stable-id>", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 363f42cd..de7a230f 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1314,41 +1314,23 @@ async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, pa method: "POST", body: payload, maxResponseBytes: 240_000, + timeoutMs: config.timeoutMs, }); if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response }; return summarizeClaudeQqProxyFailure(response, endpoint); } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), config.timeoutMs); - try { - const response = await fetch(normalizeClaudeQqEndpoint(config.baseUrl, endpoint), { - method: "POST", - signal: controller.signal, - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify(payload), - }); - const parsed = await parseGitHubResponse(response); - const bodyFailed = isRecord(parsed) && (parsed.ok === false || parsed.success === false); - if (response.ok && !bodyFailed) return { ok: true, endpoint, status: response.status, response: parsed }; - return { - ok: false, - endpoint, - status: response.status, - degradedReason: bodyFailed ? "upstream-rejected" : "http-failed", - message: isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : response.statusText, - response: sanitizedErrorDetails(parsed), - }; - } catch (error) { - return { - ok: false, - endpoint, - degradedReason: "network-proxy-failed", - message: error instanceof Error ? error.message : String(error), - }; - } finally { - clearTimeout(timeout); - } + return { + ok: false, + endpoint, + degradedReason: "notification-path-unavailable", + message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.", + response: { + baseUrl: sanitizeUrlForOutput(config.baseUrl), + requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, + recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw", + }, + }; } async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> { @@ -1377,6 +1359,7 @@ async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: strin } function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> { + const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl); return { enabled: config.enabled, issueNumber, @@ -1395,10 +1378,18 @@ function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: }, claudeqq: { dryRun: true, - wouldSend: config.enabled && diff.ok, + wouldSend: config.enabled && diff.ok && proxyBasePath !== null, baseUrl: sanitizeUrlForOutput(config.baseUrl), + proxyBasePath, + servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"), target: maskedTarget(config), timeoutMs: config.timeoutMs, + ...(proxyBasePath === null + ? { + blockedReason: "notification-path-unavailable", + recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw", + } + : {}), }, }; } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index d1422e20..93835be8 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -47,7 +47,7 @@ export function rootHelp(): unknown { { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." }, - { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." }, + { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview; generates <=200 char Chinese ClaudeQQ drafts plus backend-core proxy command without live bridges or message sends." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, @@ -219,11 +219,12 @@ function commanderHelp(): unknown { "bun scripts/cli.ts commander smoke --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]", ], - description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview.", + description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview with a backend-core ClaudeQQ proxy command.", boundary: [ "the current skeleton is local-only and never attaches to live bridges", "dry-run commands never open SSH, PTY, or stdio bridges", - "high-risk actions only produce a ClaudeQQ approval draft", + "high-risk actions only produce a <=200 char Chinese ClaudeQQ approval draft and notification-path-unavailable blocker", + "authorized future sends must use backend-core /api/microservices/claudeqq/proxy, not local skill or powershell paths", "token and secret values must never be printed", ], reference: "docs/reference/host-codex-commander.md", diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index 5d2f92a9..b4220b70 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -142,10 +142,10 @@ function backendCoreContainerMissing(stderr: string): boolean { return stderr.includes("No such container: unidesk-backend-core"); } -export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown { +export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number; timeoutMs?: number }): unknown { if (!path.startsWith("/")) throw new Error("core internal path must start with /"); const command = dockerCoreFetchCommand(path, init); - const result = runCommand(command, repoRoot); + const result = runCommand(command, repoRoot, { timeoutMs: init?.timeoutMs }); if (result.exitCode !== 0) { if (backendCoreContainerMissing(result.stderr)) { const envPath = join(repoRoot, ".state", "docker-compose.env"); @@ -170,6 +170,7 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?: return { ok: false, exitCode: result.exitCode, + timedOut: result.timedOut, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), ...(codeQueueStableProxy ? { diff --git a/src/components/microservices/host-codex-commander/src/approval-notification.ts b/src/components/microservices/host-codex-commander/src/approval-notification.ts new file mode 100644 index 00000000..b818d2a5 --- /dev/null +++ b/src/components/microservices/host-codex-commander/src/approval-notification.ts @@ -0,0 +1,163 @@ +import { redactJsonValue, redactText } from "./redaction"; + +export const commanderApprovalNotificationMaxChars = 200; +export const commanderApprovalClaudeQqProxyBasePath = "/api/microservices/claudeqq/proxy"; +export const commanderApprovalClaudeQqPrimaryEndpoint = "/api/push/text"; +export const commanderApprovalClaudeQqFallbackEndpoint = "/api/send/text"; +export const commanderApprovalClaudeQqDefaultUserId = "645275593"; +export const commanderApprovalClaudeQqTimeoutMs = 15_000; + +export interface CommanderApprovalNotificationDraft { + ok: true; + channel: "claudeqq"; + language: "zh-CN"; + format: "plain-text"; + message: string; + chars: number; + maxChars: number; + markdownAllowed: false; + containsMarkdownSyntax: boolean; + oneParagraph: boolean; + redactionsApplied: number; +} + +export interface CommanderApprovalNotificationPathUnavailable { + ok: false; + error: "notification-path-unavailable"; + degradedReason: "source-contract-dry-run-only"; + mutation: false; + message: string; + servicePath: string; + fallbackServicePath: string; + backendCoreProxyCommand: string; + timeoutMs: number; + forbiddenLocalPaths: string[]; +} + +const actionLabels: Record<string, string> = { + "code-queue-backend-restart": "重启 Code Queue backend", + "code-queue-backend-rebuild": "重建 Code Queue backend 容器", + "code-queue-execution-plane-restart": "重启 Code Queue 执行面", + "code-queue-task-interrupt": "interrupt 运行中任务", + "code-queue-task-cancel": "cancel 运行中任务", + "prod-runtime-mutation": "修改生产运行态", +}; + +function charLength(value: string): number { + return Array.from(value).length; +} + +function clampChars(value: string, maxChars: number): string { + const chars = Array.from(value); + if (chars.length <= maxChars) return value; + if (maxChars <= 1) return "…"; + return `${chars.slice(0, maxChars - 1).join("")}…`; +} + +function oneParagraph(value: string): string { + return value.replace(/\s+/gu, " ").trim(); +} + +function removeMarkdownSyntax(value: string): string { + return value + .replace(/[`*_#[\]|]/gu, "") + .replace(/^\s*[-+]\s+/gu, "") + .trim(); +} + +function containsMarkdownSyntax(value: string): boolean { + return /[`*_#[\]|]/u.test(value) || /\n/u.test(value); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function commanderApprovalClaudeQqPayload(message: string, userId = commanderApprovalClaudeQqDefaultUserId): Record<string, unknown> { + return { + targetType: "private", + userId, + message, + }; +} + +export function commanderApprovalClaudeQqProxyCommand(message: string): string { + const payload = JSON.stringify(commanderApprovalClaudeQqPayload(message)); + return `bun scripts/cli.ts microservice proxy claudeqq ${commanderApprovalClaudeQqPrimaryEndpoint} --method POST --body-json ${shellQuote(payload)} --raw`; +} + +export function buildCommanderApprovalNotificationDraft(input: { action: string; taskId?: string | null; reason: string }): CommanderApprovalNotificationDraft { + const redacted = redactText(input.reason); + const label = actionLabels[input.action] ?? removeMarkdownSyntax(input.action); + const target = input.taskId === null || input.taskId === undefined || input.taskId.length === 0 + ? "" + : `,目标 ${removeMarkdownSyntax(input.taskId)}`; + const prefix = `请审批高风险 Code Queue 恢复:拟执行「${label}」${target}。原因:`; + const suffix = "。未获明确同意前只做只读诊断和 issue 记录。"; + const fallbackReason = "需用户确认后才能执行"; + const reason = removeMarkdownSyntax(oneParagraph(redacted.text || fallbackReason)) || fallbackReason; + const budget = Math.max(1, commanderApprovalNotificationMaxChars - charLength(prefix) - charLength(suffix)); + const message = `${prefix}${clampChars(reason, budget)}${suffix}`; + const normalized = clampChars(oneParagraph(removeMarkdownSyntax(message)), commanderApprovalNotificationMaxChars); + return { + ok: true, + channel: "claudeqq", + language: "zh-CN", + format: "plain-text", + message: normalized, + chars: charLength(normalized), + maxChars: commanderApprovalNotificationMaxChars, + markdownAllowed: false, + containsMarkdownSyntax: containsMarkdownSyntax(normalized), + oneParagraph: !normalized.includes("\n"), + redactionsApplied: redacted.redactionsApplied, + }; +} + +export function commanderApprovalNotificationPathUnavailable(message: string): CommanderApprovalNotificationPathUnavailable { + return { + ok: false, + error: "notification-path-unavailable", + degradedReason: "source-contract-dry-run-only", + mutation: false, + message: "host-codex-commander 当前阶段只生成审批通知草案;正式发送必须在授权后走 backend-core microservice proxy,不能回退到本机 ClaudeQQ skill/powershell。", + servicePath: `${commanderApprovalClaudeQqProxyBasePath}${commanderApprovalClaudeQqPrimaryEndpoint}`, + fallbackServicePath: `${commanderApprovalClaudeQqProxyBasePath}${commanderApprovalClaudeQqFallbackEndpoint}`, + backendCoreProxyCommand: commanderApprovalClaudeQqProxyCommand(message), + timeoutMs: commanderApprovalClaudeQqTimeoutMs, + forbiddenLocalPaths: [ + "/root/.agents/skills/claudeqq", + "powershell.exe", + "local ClaudeQQ skill server", + "localhost:9082", + ], + }; +} + +export function commanderApprovalProxyFailureSummary(response: unknown, endpoint = commanderApprovalClaudeQqPrimaryEndpoint): Record<string, unknown> { + const redacted = redactJsonValue(response) as unknown; + const record = typeof redacted === "object" && redacted !== null && !Array.isArray(redacted) ? redacted as Record<string, unknown> : {}; + const status = typeof record.status === "number" ? record.status : null; + const message = typeof record.error === "string" + ? record.error + : typeof record.message === "string" + ? record.message + : typeof record.stderrTail === "string" + ? record.stderrTail.slice(-500) + : "ClaudeQQ microservice proxy request failed"; + return { + ok: false, + error: "notification-proxy-failed", + degradedReason: "microservice-proxy-failed", + runnerDisposition: "infra-blocked", + endpoint, + servicePath: `${commanderApprovalClaudeQqProxyBasePath}${endpoint}`, + status, + message: redactText(message).text, + response: redacted, + rollback: { + issueUpdateRolledBack: false, + policy: "ClaudeQQ notification is best-effort; record the failure in the related issue or commander brief instead of rolling back completed GitHub issue updates.", + }, + }; +} diff --git a/src/components/microservices/host-codex-commander/src/contract.ts b/src/components/microservices/host-codex-commander/src/contract.ts index 5a12564c..8a8d70e8 100644 --- a/src/components/microservices/host-codex-commander/src/contract.ts +++ b/src/components/microservices/host-codex-commander/src/contract.ts @@ -94,7 +94,7 @@ export function commanderContract(): CommanderContract { hostCodexProcess: "Long-lived Codex process on the master server host.", controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.", codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.", - claudeqq: "Approval draft destination only; no messages are sent from this stage.", + claudeqq: "Approval draft destination only in this stage; any future authorized send must use backend-core /api/microservices/claudeqq/proxy, never the local skill/powershell path.", githubPrCloseout: "PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks; commander review remains required for high-risk, ambiguous, failed-check, production, release, security, database, or runtime scopes.", }, requiredCapabilities: [ @@ -129,7 +129,7 @@ export function commanderContract(): CommanderContract { }, safetyBoundary: { phaseOneMutationAllowed: false, - forbiddenWithoutExplicitUserApproval: commanderHighRiskActions, + forbiddenWithoutExplicitUserApproval: [...commanderHighRiskActions], alwaysForbidden: [ "print-token-values", "read-token-files-for-display", diff --git a/src/components/microservices/host-codex-commander/src/state.ts b/src/components/microservices/host-codex-commander/src/state.ts index 2610b415..36d5fe42 100644 --- a/src/components/microservices/host-codex-commander/src/state.ts +++ b/src/components/microservices/host-codex-commander/src/state.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { buildCommanderApprovalNotificationDraft, commanderApprovalNotificationPathUnavailable } from "./approval-notification"; import type { CommanderApprovalState, CommanderPromptState, CommanderSessionState } from "./contract"; import { redactJsonValue, redactText } from "./redaction"; @@ -180,7 +181,7 @@ export function readCommanderSession(config: CommanderStorageConfig, sessionId = export function writeCommanderSession(config: CommanderStorageConfig, session: CommanderSessionRecord): CommanderSessionRecord { const path = stateFilePath(config.rootDir, session.sessionId); ensureDir(dirname(path)); - const normalized = normalizeSessionRecord(session, session.sessionId); + const normalized = normalizeSessionRecord(session as unknown as Record<string, unknown>, session.sessionId); writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); return normalized; } @@ -209,7 +210,7 @@ export function readCommanderApproval(config: CommanderStorageConfig, approvalId export function writeCommanderApproval(config: CommanderStorageConfig, approval: CommanderApprovalDraftRecord): CommanderApprovalDraftRecord { const path = approvalFilePath(config.rootDir, approval.id); ensureDir(dirname(path)); - const normalized = normalizeApprovalRecord(approval, approval.id); + const normalized = normalizeApprovalRecord(approval as unknown as Record<string, unknown>, approval.id); writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); return normalized; } @@ -219,6 +220,8 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): const reason = redactText(input.reason); const action = String(input.action || "unknown"); const taskId = input.taskId ?? null; + const notificationDraft = buildCommanderApprovalNotificationDraft({ action, taskId, reason: input.reason }); + const notificationPath = commanderApprovalNotificationPathUnavailable(notificationDraft.message); const previewMarkdown = [ "# Commander approval draft", "", @@ -233,9 +236,14 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): "", reason.text || "(empty)", "", + "## ClaudeQQ notification draft", + "", + notificationDraft.message, + "", "## Boundary", "", "- ClaudeQQ send is not implemented in this skeleton.", + "- If an authorized send is required, use backend-core /api/microservices/claudeqq/proxy only.", "- Approval is preview-only until explicit user approval is recorded.", ].join("\n"); return { @@ -255,10 +263,16 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): redactionsApplied: reason.redactionsApplied, requiresExplicitUserApproval: true, sendImplemented: false, + notificationDraft, claudeqq: { mutation: false, - target: "preview-only", - messageTemplate: `Approval required for ${action}. Reason: ${reason.text}.`, + target: "configured primary user private chat", + endpointShape: `POST ${notificationPath.servicePath}`, + fallbackEndpointShape: `POST ${notificationPath.fallbackServicePath}`, + messageTemplate: notificationDraft.message, + sendImplemented: false, + dryRunNoClaudeQqSend: true, + notificationPath, }, blockedUntilApproved: [action], }) as Record<string, unknown>,