fix(commander): add ClaudeQQ approval proxy draft path (#134)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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