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,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>,