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
@@ -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 恢复需要 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`);
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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 ? {