fix(commander): add ClaudeQQ approval proxy draft path (#134)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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 <commitId>`:旧 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 <id>]` / `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`。
|
||||
|
||||
@@ -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 <manifest>`,仍不 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 '<payload>' --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 <number|owner/repo#number> [--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 <number> --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 <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`。
|
||||
|
||||
@@ -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,并继续按轮询和恢复规则推进。
|
||||
|
||||
|
||||
@@ -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`,并设置超时和结构化失败输出。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+20
-22
@@ -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>",
|
||||
|
||||
+22
-31
@@ -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",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user