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
+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 ? {