fix(commander): add ClaudeQQ approval proxy draft path (#134)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-05-23 19:22:07 +08:00
committed by GitHub
parent 431d6bd25b
commit 86f388722f
17 changed files with 424 additions and 80 deletions
@@ -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 恢复需要 interrupttoken=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`);