86f388722f
Co-authored-by: Codex <codex@noreply.local>
165 lines
7.8 KiB
TypeScript
165 lines
7.8 KiB
TypeScript
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`);
|