mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
Wire diagnostics through the core chat command (#72936)
* feat: wire codex diagnostics feedback * fix: harden codex diagnostics hints * fix: neutralize codex diagnostics output * fix: tighten codex diagnostics safeguards * fix: bound codex diagnostics feedback output * fix: tighten codex diagnostics throttling * fix: confirm codex diagnostics uploads * docs: clarify codex diagnostics add-on * fix: route diagnostics through core command * fix: tighten diagnostics authorization * fix: pin diagnostics to bundled codex command * fix: limit owner status in plugin commands * fix: scope diagnostics confirmations * fix: scope codex diagnostics cooldowns * fix: harden codex diagnostics ownership scopes * fix: harden diagnostics command trust and display * fix: keep diagnostics command trust internal * fix: clarify diagnostics exec boundary * fix: consume codex diagnostics confirmations atomically * test: include codex diagnostics binding metadata * test: use string codex binding timestamps * fix: keep reserved command trust host-only * fix: harden diagnostics trust and resume hints * wire diagnostics through exec approval * fix: keep diagnostics tests aligned with bundled root trust * fix telegram diagnostics owner auth * route trajectory exports through exec approval * fix trajectory exec command encoding * fix telegram group owner auth * fix export trajectory approval hardening * fix pairing command owner bootstrap * fix telegram owner exec approvals * fix: make diagnostics approval flow pasteable * fix: route native sensitive command followups * fix: invoke diagnostics exports with current cli * fix: refresh exec approval protocol models * fix: list codex diagnostics from thread bindings * fix: fold codex diagnostics into exec approval * fix: preserve diagnostics approval line breaks * docs: clarify diagnostics codex workflow
This commit is contained in:
@@ -3,6 +3,7 @@ import { CodexAppServerRpcError } from "./client.js";
|
||||
export const CODEX_CONTROL_METHODS = {
|
||||
account: "account/read",
|
||||
compact: "thread/compact/start",
|
||||
feedback: "feedback/upload",
|
||||
listMcpServers: "mcpServerStatus/list",
|
||||
listSkills: "skills/list",
|
||||
listThreads: "thread/list",
|
||||
|
||||
@@ -102,6 +102,7 @@ type CodexAppServerRequestResultMap = {
|
||||
initialize: CodexInitializeResponse;
|
||||
"account/rateLimits/read": v2.GetAccountRateLimitsResponse;
|
||||
"account/read": v2.GetAccountResponse;
|
||||
"feedback/upload": v2.FeedbackUploadResponse;
|
||||
"mcpServerStatus/list": v2.ListMcpServerStatusResponse;
|
||||
"model/list": v2.ModelListResponse;
|
||||
"review/start": v2.ReviewStartResponse;
|
||||
|
||||
@@ -148,6 +148,7 @@ export function buildHelp(): string {
|
||||
"- /codex detach",
|
||||
"- /codex compact",
|
||||
"- /codex review",
|
||||
"- /codex diagnostics [note]",
|
||||
"- /codex computer-use [status|install]",
|
||||
"- /codex account",
|
||||
"- /codex mcp",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js";
|
||||
import {
|
||||
@@ -116,6 +117,63 @@ type ParsedComputerUseArgs = {
|
||||
help?: boolean;
|
||||
};
|
||||
|
||||
type ParsedDiagnosticsArgs =
|
||||
| { action: "request"; note: string }
|
||||
| { action: "confirm"; token: string }
|
||||
| { action: "cancel"; token: string };
|
||||
|
||||
type CodexDiagnosticsTarget = {
|
||||
threadId: string;
|
||||
sessionFile: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
channel?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: string | number;
|
||||
threadParentId?: string;
|
||||
};
|
||||
|
||||
type PendingCodexDiagnosticsConfirmation = {
|
||||
token: string;
|
||||
targets: CodexDiagnosticsTarget[];
|
||||
note?: string;
|
||||
senderId: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
channelId?: string;
|
||||
messageThreadId?: string;
|
||||
threadParentId?: string;
|
||||
sessionKey?: string;
|
||||
scopeKey: string;
|
||||
privateRouted?: boolean;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const CODEX_DIAGNOSTICS_SOURCE = "openclaw-diagnostics";
|
||||
const CODEX_DIAGNOSTICS_REASON_MAX_CHARS = 2048;
|
||||
const CODEX_DIAGNOSTICS_COOLDOWN_MS = 60_000;
|
||||
const CODEX_DIAGNOSTICS_ERROR_MAX_CHARS = 500;
|
||||
const CODEX_DIAGNOSTICS_COOLDOWN_MAX_THREADS = 100;
|
||||
const CODEX_DIAGNOSTICS_COOLDOWN_MAX_SCOPES = 100;
|
||||
const CODEX_DIAGNOSTICS_CONFIRMATION_TTL_MS = 5 * 60_000;
|
||||
const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE = 100;
|
||||
const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES = 100;
|
||||
const CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS = 128;
|
||||
const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
||||
|
||||
const lastCodexDiagnosticsUploadByThread = new Map<string, number>();
|
||||
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
|
||||
const pendingCodexDiagnosticsConfirmations = new Map<string, PendingCodexDiagnosticsConfirmation>();
|
||||
const pendingCodexDiagnosticsConfirmationTokensByScope = new Map<string, string[]>();
|
||||
|
||||
export function resetCodexDiagnosticsFeedbackStateForTests(): void {
|
||||
lastCodexDiagnosticsUploadByThread.clear();
|
||||
lastCodexDiagnosticsUploadByScope.clear();
|
||||
pendingCodexDiagnosticsConfirmations.clear();
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.clear();
|
||||
}
|
||||
|
||||
export async function handleCodexSubcommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
|
||||
@@ -188,6 +246,15 @@ export async function handleCodexSubcommand(
|
||||
),
|
||||
};
|
||||
}
|
||||
if (normalized === "diagnostics") {
|
||||
return await handleCodexDiagnosticsFeedback(
|
||||
deps,
|
||||
ctx,
|
||||
options.pluginConfig,
|
||||
rest.join(" "),
|
||||
"/codex diagnostics",
|
||||
);
|
||||
}
|
||||
if (normalized === "computer-use" || normalized === "computeruse") {
|
||||
return {
|
||||
text: await handleComputerUseCommand(deps, options.pluginConfig, rest),
|
||||
@@ -484,6 +551,834 @@ async function resolveControlSessionFile(ctx: PluginCommandContext): Promise<str
|
||||
return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile;
|
||||
}
|
||||
|
||||
async function handleCodexDiagnosticsFeedback(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
args: string,
|
||||
commandPrefix: string,
|
||||
): Promise<PluginCommandResult> {
|
||||
if (ctx.senderIsOwner !== true) {
|
||||
return { text: "Only an owner can send Codex diagnostics." };
|
||||
}
|
||||
const parsed = parseDiagnosticsArgs(args);
|
||||
if (parsed.action === "confirm") {
|
||||
return {
|
||||
text: await confirmCodexDiagnosticsFeedback(deps, ctx, pluginConfig, parsed.token),
|
||||
};
|
||||
}
|
||||
if (parsed.action === "cancel") {
|
||||
return { text: cancelCodexDiagnosticsFeedback(ctx, parsed.token) };
|
||||
}
|
||||
if (ctx.diagnosticsUploadApproved === true) {
|
||||
return {
|
||||
text: await sendCodexDiagnosticsFeedbackForContext(deps, ctx, pluginConfig, parsed.note),
|
||||
};
|
||||
}
|
||||
if (ctx.diagnosticsPreviewOnly === true) {
|
||||
return {
|
||||
text: await previewCodexDiagnosticsFeedbackApproval(deps, ctx, parsed.note),
|
||||
};
|
||||
}
|
||||
return await requestCodexDiagnosticsFeedbackApproval(
|
||||
deps,
|
||||
ctx,
|
||||
pluginConfig,
|
||||
parsed.note,
|
||||
commandPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestCodexDiagnosticsFeedbackApproval(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
note: string,
|
||||
commandPrefix: string,
|
||||
): Promise<PluginCommandResult> {
|
||||
if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) {
|
||||
return {
|
||||
text: "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.",
|
||||
};
|
||||
}
|
||||
const targets = await resolveCodexDiagnosticsTargets(deps, ctx);
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
text: [
|
||||
"No Codex thread is attached to this OpenClaw session yet.",
|
||||
"Use /codex threads to find a thread, then /codex resume <thread-id> before sending diagnostics.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
const now = Date.now();
|
||||
const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, now);
|
||||
if (cooldownMessage) {
|
||||
return { text: cooldownMessage };
|
||||
}
|
||||
if (!ctx.senderId) {
|
||||
return {
|
||||
text: "Cannot send Codex diagnostics because this command did not include a sender identity.",
|
||||
};
|
||||
}
|
||||
const reason = normalizeDiagnosticsReason(note);
|
||||
const token = createCodexDiagnosticsConfirmation({
|
||||
targets,
|
||||
note: reason,
|
||||
senderId: ctx.senderId,
|
||||
channel: ctx.channel,
|
||||
scopeKey: readCodexDiagnosticsCooldownScope(ctx),
|
||||
privateRouted: ctx.diagnosticsPrivateRouted === true,
|
||||
...readCodexDiagnosticsConfirmationScope(ctx),
|
||||
now,
|
||||
});
|
||||
const confirmCommand = `${commandPrefix} confirm ${token}`;
|
||||
const cancelCommand = `${commandPrefix} cancel ${token}`;
|
||||
const displayReason = reason ? escapeCodexChatText(formatCodexTextForDisplay(reason)) : undefined;
|
||||
const lines = [
|
||||
targets.length === 1 ? "Codex runtime thread detected." : "Codex runtime threads detected.",
|
||||
`Codex diagnostics can send ${targets.length === 1 ? "this thread's feedback bundle" : "these threads' feedback bundles"} to OpenAI servers.`,
|
||||
"Codex sessions:",
|
||||
...formatCodexDiagnosticsTargetLines(targets),
|
||||
...(displayReason ? [`Note: ${displayReason}`] : []),
|
||||
"Included: Codex logs and spawned Codex subthreads when available.",
|
||||
`To send: ${confirmCommand}`,
|
||||
`To cancel: ${cancelCommand}`,
|
||||
"This request expires in 5 minutes.",
|
||||
];
|
||||
return {
|
||||
text: lines.join("\n"),
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Send diagnostics", value: confirmCommand, style: "danger" },
|
||||
{ label: "Cancel", value: cancelCommand, style: "secondary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function previewCodexDiagnosticsFeedbackApproval(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
note: string,
|
||||
): Promise<string> {
|
||||
if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) {
|
||||
return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const targets = await resolveCodexDiagnosticsTargets(deps, ctx);
|
||||
if (targets.length === 0) {
|
||||
return [
|
||||
"No Codex thread is attached to this OpenClaw session yet.",
|
||||
"Use /codex threads to find a thread, then /codex resume <thread-id> before sending diagnostics.",
|
||||
].join("\n");
|
||||
}
|
||||
const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, Date.now(), {
|
||||
includeThreadId: false,
|
||||
});
|
||||
if (cooldownMessage) {
|
||||
return cooldownMessage;
|
||||
}
|
||||
const reason = normalizeDiagnosticsReason(note);
|
||||
const displayReason = reason ? escapeCodexChatText(formatCodexTextForDisplay(reason)) : undefined;
|
||||
return [
|
||||
targets.length === 1 ? "Codex runtime thread detected." : "Codex runtime threads detected.",
|
||||
`Approving diagnostics will also send ${targets.length === 1 ? "this thread's feedback bundle" : "these threads' feedback bundles"} to OpenAI servers.`,
|
||||
"The completed diagnostics reply will list the OpenClaw session ids and Codex thread ids that were sent.",
|
||||
...(displayReason ? [`Note: ${displayReason}`] : []),
|
||||
"Included: Codex logs and spawned Codex subthreads when available.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function confirmCodexDiagnosticsFeedback(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const pending = readPendingCodexDiagnosticsConfirmation(token, Date.now());
|
||||
if (!pending) {
|
||||
return "No pending Codex diagnostics confirmation was found. Run /diagnostics again to create a fresh request.";
|
||||
}
|
||||
if (!pending.senderId || !ctx.senderId) {
|
||||
return "Cannot confirm Codex diagnostics because this command did not include the original sender identity.";
|
||||
}
|
||||
if (pending.senderId !== ctx.senderId) {
|
||||
return "Only the user who requested these Codex diagnostics can confirm the upload.";
|
||||
}
|
||||
if (pending.channel !== ctx.channel) {
|
||||
return "This Codex diagnostics confirmation belongs to a different channel.";
|
||||
}
|
||||
const scopeMismatch = readCodexDiagnosticsScopeMismatch(pending, ctx);
|
||||
if (scopeMismatch) {
|
||||
return scopeMismatch.confirmMessage;
|
||||
}
|
||||
deletePendingCodexDiagnosticsConfirmation(token);
|
||||
if (!pending.privateRouted && !(await hasAnyCodexDiagnosticsSessionFile(ctx))) {
|
||||
return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const currentTargets = pending.privateRouted
|
||||
? await resolvePendingCodexDiagnosticsTargets(deps, pending.targets)
|
||||
: await resolveCodexDiagnosticsTargets(deps, ctx);
|
||||
if (!codexDiagnosticsTargetsMatch(pending.targets, currentTargets)) {
|
||||
return "The Codex diagnostics sessions changed before confirmation. Run /diagnostics again for the current threads.";
|
||||
}
|
||||
return await sendCodexDiagnosticsFeedbackForTargets(
|
||||
deps,
|
||||
ctx,
|
||||
pluginConfig,
|
||||
pending.note ?? "",
|
||||
pending.targets,
|
||||
);
|
||||
}
|
||||
|
||||
function cancelCodexDiagnosticsFeedback(ctx: PluginCommandContext, token: string): string {
|
||||
const pending = readPendingCodexDiagnosticsConfirmation(token, Date.now());
|
||||
if (!pending) {
|
||||
return "No pending Codex diagnostics confirmation was found.";
|
||||
}
|
||||
if (!pending.senderId || !ctx.senderId) {
|
||||
return "Cannot cancel Codex diagnostics because this command did not include the original sender identity.";
|
||||
}
|
||||
if (pending.senderId !== ctx.senderId) {
|
||||
return "Only the user who requested these Codex diagnostics can cancel the upload.";
|
||||
}
|
||||
if (pending.channel !== ctx.channel) {
|
||||
return "This Codex diagnostics confirmation belongs to a different channel.";
|
||||
}
|
||||
const scopeMismatch = readCodexDiagnosticsScopeMismatch(pending, ctx);
|
||||
if (scopeMismatch) {
|
||||
return scopeMismatch.cancelMessage;
|
||||
}
|
||||
deletePendingCodexDiagnosticsConfirmation(token);
|
||||
return [
|
||||
"Codex diagnostics upload canceled.",
|
||||
"Codex sessions:",
|
||||
...formatCodexDiagnosticsTargetLines(pending.targets),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function sendCodexDiagnosticsFeedbackForContext(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
note: string,
|
||||
): Promise<string> {
|
||||
if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) {
|
||||
return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const targets = await resolveCodexDiagnosticsTargets(deps, ctx);
|
||||
if (targets.length === 0) {
|
||||
return [
|
||||
"No Codex thread is attached to this OpenClaw session yet.",
|
||||
"Use /codex threads to find a thread, then /codex resume <thread-id> before sending diagnostics.",
|
||||
].join("\n");
|
||||
}
|
||||
return await sendCodexDiagnosticsFeedbackForTargets(deps, ctx, pluginConfig, note, targets);
|
||||
}
|
||||
|
||||
async function sendCodexDiagnosticsFeedbackForTargets(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
note: string,
|
||||
targets: CodexDiagnosticsTarget[],
|
||||
): Promise<string> {
|
||||
if (targets.length === 0) {
|
||||
return [
|
||||
"No Codex thread is attached to this OpenClaw session yet.",
|
||||
"Use /codex threads to find a thread, then /codex resume <thread-id> before sending diagnostics.",
|
||||
].join("\n");
|
||||
}
|
||||
const now = Date.now();
|
||||
const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, now);
|
||||
if (cooldownMessage) {
|
||||
return cooldownMessage;
|
||||
}
|
||||
const reason = normalizeDiagnosticsReason(note);
|
||||
const sent: CodexDiagnosticsTarget[] = [];
|
||||
const failed: Array<{ target: CodexDiagnosticsTarget; error: string }> = [];
|
||||
for (const target of targets) {
|
||||
const response = await deps.safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.feedback,
|
||||
{
|
||||
classification: "bug",
|
||||
threadId: target.threadId,
|
||||
includeLogs: true,
|
||||
tags: buildDiagnosticsTags(ctx),
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
failed.push({ target, error: response.error });
|
||||
continue;
|
||||
}
|
||||
const responseThreadId = isJsonObject(response.value)
|
||||
? readString(response.value, "threadId")
|
||||
: undefined;
|
||||
sent.push({ ...target, threadId: responseThreadId ?? target.threadId });
|
||||
recordCodexDiagnosticsUpload(target.threadId, ctx, now);
|
||||
}
|
||||
return formatCodexDiagnosticsUploadResult(sent, failed);
|
||||
}
|
||||
|
||||
async function hasAnyCodexDiagnosticsSessionFile(ctx: PluginCommandContext): Promise<boolean> {
|
||||
if (await resolveControlSessionFile(ctx)) {
|
||||
return true;
|
||||
}
|
||||
return (ctx.diagnosticsSessions ?? []).some((session) => Boolean(session.sessionFile));
|
||||
}
|
||||
|
||||
async function resolveCodexDiagnosticsTargets(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
): Promise<CodexDiagnosticsTarget[]> {
|
||||
const activeSessionFile = await resolveControlSessionFile(ctx);
|
||||
const candidates: CodexDiagnosticsTarget[] = [];
|
||||
if (activeSessionFile) {
|
||||
candidates.push({
|
||||
threadId: "",
|
||||
sessionFile: activeSessionFile,
|
||||
sessionKey: ctx.sessionKey,
|
||||
sessionId: ctx.sessionId,
|
||||
channel: ctx.channel,
|
||||
channelId: ctx.channelId,
|
||||
accountId: ctx.accountId,
|
||||
messageThreadId: ctx.messageThreadId,
|
||||
threadParentId: ctx.threadParentId,
|
||||
});
|
||||
}
|
||||
for (const session of ctx.diagnosticsSessions ?? []) {
|
||||
if (!session.sessionFile) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
threadId: "",
|
||||
sessionFile: session.sessionFile,
|
||||
sessionKey: session.sessionKey,
|
||||
sessionId: session.sessionId,
|
||||
channel: session.channel,
|
||||
channelId: session.channelId,
|
||||
accountId: session.accountId,
|
||||
messageThreadId: session.messageThreadId,
|
||||
threadParentId: session.threadParentId,
|
||||
});
|
||||
}
|
||||
const seenSessionFiles = new Set<string>();
|
||||
const seenThreadIds = new Set<string>();
|
||||
const targets: CodexDiagnosticsTarget[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (seenSessionFiles.has(candidate.sessionFile)) {
|
||||
continue;
|
||||
}
|
||||
seenSessionFiles.add(candidate.sessionFile);
|
||||
const binding = await deps.readCodexAppServerBinding(candidate.sessionFile);
|
||||
if (!binding?.threadId || seenThreadIds.has(binding.threadId)) {
|
||||
continue;
|
||||
}
|
||||
seenThreadIds.add(binding.threadId);
|
||||
targets.push({ ...candidate, threadId: binding.threadId });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
async function resolvePendingCodexDiagnosticsTargets(
|
||||
deps: CodexCommandDeps,
|
||||
targets: readonly CodexDiagnosticsTarget[],
|
||||
): Promise<CodexDiagnosticsTarget[]> {
|
||||
const resolved: CodexDiagnosticsTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const binding = await deps.readCodexAppServerBinding(target.sessionFile);
|
||||
if (!binding?.threadId) {
|
||||
continue;
|
||||
}
|
||||
resolved.push({ ...target, threadId: binding.threadId });
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function codexDiagnosticsTargetsMatch(
|
||||
expected: readonly CodexDiagnosticsTarget[],
|
||||
actual: readonly CodexDiagnosticsTarget[],
|
||||
): boolean {
|
||||
const expectedThreadIds = expected.map((target) => target.threadId).toSorted();
|
||||
const actualThreadIds = actual.map((target) => target.threadId).toSorted();
|
||||
return (
|
||||
expectedThreadIds.length === actualThreadIds.length &&
|
||||
expectedThreadIds.every((threadId, index) => threadId === actualThreadIds[index])
|
||||
);
|
||||
}
|
||||
|
||||
function formatCodexDiagnosticsUploadResult(
|
||||
sent: readonly CodexDiagnosticsTarget[],
|
||||
failed: ReadonlyArray<{ target: CodexDiagnosticsTarget; error: string }>,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
if (sent.length > 0) {
|
||||
lines.push("Codex diagnostics sent to OpenAI servers:");
|
||||
lines.push(...formatCodexDiagnosticsTargetLines(sent));
|
||||
lines.push("Included Codex logs and spawned Codex subthreads when available.");
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
if (lines.length > 0) {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push("Could not send Codex diagnostics:");
|
||||
lines.push(
|
||||
...failed.map(
|
||||
({ target, error }) =>
|
||||
`${formatCodexDiagnosticsTargetLine(target)}: ${formatCodexErrorForDisplay(error)}`,
|
||||
),
|
||||
);
|
||||
lines.push("Inspect locally:");
|
||||
lines.push(
|
||||
...failed.map(({ target }) => `- ${formatCodexResumeCommandForDisplay(target.threadId)}`),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatCodexDiagnosticsTargetLines(targets: readonly CodexDiagnosticsTarget[]): string[] {
|
||||
return targets.flatMap((target, index) => {
|
||||
const lines = formatCodexDiagnosticsTargetBlock(target, index);
|
||||
return index < targets.length - 1 ? [...lines, ""] : lines;
|
||||
});
|
||||
}
|
||||
|
||||
function formatCodexDiagnosticsTargetBlock(
|
||||
target: CodexDiagnosticsTarget,
|
||||
index: number,
|
||||
): string[] {
|
||||
const lines = [`Session ${index + 1}`];
|
||||
if (target.channel) {
|
||||
lines.push(`Channel: ${formatCodexValueForDisplay(target.channel)}`);
|
||||
}
|
||||
if (target.sessionKey) {
|
||||
lines.push(`OpenClaw session key: ${formatCodexCopyableValueForDisplay(target.sessionKey)}`);
|
||||
}
|
||||
if (target.sessionId) {
|
||||
lines.push(`OpenClaw session id: ${formatCodexCopyableValueForDisplay(target.sessionId)}`);
|
||||
}
|
||||
lines.push(`Codex thread id: ${formatCodexCopyableValueForDisplay(target.threadId)}`);
|
||||
lines.push(`Inspect locally: ${formatCodexResumeCommandForDisplay(target.threadId)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatCodexDiagnosticsTargetLine(target: CodexDiagnosticsTarget): string {
|
||||
const parts: string[] = [];
|
||||
if (target.channel) {
|
||||
parts.push(`channel ${formatCodexValueForDisplay(target.channel)}`);
|
||||
}
|
||||
const sessionLabel = target.sessionId || target.sessionKey;
|
||||
if (sessionLabel) {
|
||||
parts.push(`OpenClaw session ${formatCodexValueForDisplay(sessionLabel)}`);
|
||||
}
|
||||
parts.push(`Codex thread ${formatCodexThreadIdForDisplay(target.threadId)}`);
|
||||
return `- ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function normalizeDiagnosticsReason(note: string): string | undefined {
|
||||
const normalized = normalizeOptionalString(note);
|
||||
return normalized ? normalized.slice(0, CODEX_DIAGNOSTICS_REASON_MAX_CHARS) : undefined;
|
||||
}
|
||||
|
||||
function parseDiagnosticsArgs(args: string): ParsedDiagnosticsArgs {
|
||||
const [action, token] = splitArgs(args);
|
||||
const normalizedAction = action?.toLowerCase();
|
||||
if ((normalizedAction === "confirm" || normalizedAction === "--confirm") && token) {
|
||||
return { action: "confirm", token };
|
||||
}
|
||||
if ((normalizedAction === "cancel" || normalizedAction === "--cancel") && token) {
|
||||
return { action: "cancel", token };
|
||||
}
|
||||
return { action: "request", note: args };
|
||||
}
|
||||
|
||||
function createCodexDiagnosticsConfirmation(params: {
|
||||
targets: CodexDiagnosticsTarget[];
|
||||
note?: string;
|
||||
senderId: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
channelId?: string;
|
||||
messageThreadId?: string;
|
||||
threadParentId?: string;
|
||||
sessionKey?: string;
|
||||
scopeKey: string;
|
||||
privateRouted?: boolean;
|
||||
now: number;
|
||||
}): string {
|
||||
prunePendingCodexDiagnosticsConfirmations(params.now);
|
||||
if (
|
||||
!pendingCodexDiagnosticsConfirmationTokensByScope.has(params.scopeKey) &&
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.size >=
|
||||
CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES
|
||||
) {
|
||||
const oldestScopeKey = pendingCodexDiagnosticsConfirmationTokensByScope.keys().next().value;
|
||||
if (typeof oldestScopeKey === "string") {
|
||||
deletePendingCodexDiagnosticsConfirmationScope(oldestScopeKey);
|
||||
}
|
||||
}
|
||||
const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(params.scopeKey) ?? [];
|
||||
while (scopeTokens.length >= CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE) {
|
||||
const oldestToken = scopeTokens.shift();
|
||||
if (!oldestToken) {
|
||||
break;
|
||||
}
|
||||
pendingCodexDiagnosticsConfirmations.delete(oldestToken);
|
||||
}
|
||||
const token = crypto.randomBytes(6).toString("hex");
|
||||
scopeTokens.push(token);
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.set(params.scopeKey, scopeTokens);
|
||||
pendingCodexDiagnosticsConfirmations.set(token, {
|
||||
token,
|
||||
targets: params.targets,
|
||||
note: params.note,
|
||||
senderId: params.senderId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
channelId: params.channelId,
|
||||
messageThreadId: params.messageThreadId,
|
||||
threadParentId: params.threadParentId,
|
||||
sessionKey: params.sessionKey,
|
||||
scopeKey: params.scopeKey,
|
||||
...(params.privateRouted === undefined ? {} : { privateRouted: params.privateRouted }),
|
||||
createdAt: params.now,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsConfirmationScope(ctx: PluginCommandContext): {
|
||||
accountId?: string;
|
||||
channelId?: string;
|
||||
messageThreadId?: string;
|
||||
threadParentId?: string;
|
||||
sessionKey?: string;
|
||||
} {
|
||||
return {
|
||||
accountId: normalizeCodexDiagnosticsScopeField(ctx.accountId),
|
||||
channelId: normalizeCodexDiagnosticsScopeField(ctx.channelId),
|
||||
messageThreadId:
|
||||
typeof ctx.messageThreadId === "string" || typeof ctx.messageThreadId === "number"
|
||||
? normalizeCodexDiagnosticsScopeField(String(ctx.messageThreadId))
|
||||
: undefined,
|
||||
threadParentId: normalizeCodexDiagnosticsScopeField(ctx.threadParentId),
|
||||
sessionKey: normalizeCodexDiagnosticsScopeField(ctx.sessionKey),
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsScopeMismatch(
|
||||
pending: PendingCodexDiagnosticsConfirmation,
|
||||
ctx: PluginCommandContext,
|
||||
):
|
||||
| {
|
||||
confirmMessage: string;
|
||||
cancelMessage: string;
|
||||
}
|
||||
| undefined {
|
||||
const current = readCodexDiagnosticsConfirmationScope(ctx);
|
||||
if (pending.accountId !== current.accountId) {
|
||||
return {
|
||||
confirmMessage: "This Codex diagnostics confirmation belongs to a different account.",
|
||||
cancelMessage: "This Codex diagnostics confirmation belongs to a different account.",
|
||||
};
|
||||
}
|
||||
if (pending.privateRouted) {
|
||||
return undefined;
|
||||
}
|
||||
if (pending.channelId !== current.channelId) {
|
||||
return {
|
||||
confirmMessage:
|
||||
"This Codex diagnostics confirmation belongs to a different channel instance.",
|
||||
cancelMessage: "This Codex diagnostics confirmation belongs to a different channel instance.",
|
||||
};
|
||||
}
|
||||
if (pending.messageThreadId !== current.messageThreadId) {
|
||||
return {
|
||||
confirmMessage: "This Codex diagnostics confirmation belongs to a different thread.",
|
||||
cancelMessage: "This Codex diagnostics confirmation belongs to a different thread.",
|
||||
};
|
||||
}
|
||||
if (pending.threadParentId !== current.threadParentId) {
|
||||
return {
|
||||
confirmMessage: "This Codex diagnostics confirmation belongs to a different parent thread.",
|
||||
cancelMessage: "This Codex diagnostics confirmation belongs to a different parent thread.",
|
||||
};
|
||||
}
|
||||
if (pending.sessionKey !== current.sessionKey) {
|
||||
return {
|
||||
confirmMessage: "This Codex diagnostics confirmation belongs to a different session.",
|
||||
cancelMessage: "This Codex diagnostics confirmation belongs to a different session.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPendingCodexDiagnosticsConfirmation(
|
||||
token: string,
|
||||
now: number,
|
||||
): PendingCodexDiagnosticsConfirmation | undefined {
|
||||
prunePendingCodexDiagnosticsConfirmations(now);
|
||||
return pendingCodexDiagnosticsConfirmations.get(token);
|
||||
}
|
||||
|
||||
function prunePendingCodexDiagnosticsConfirmations(now: number): void {
|
||||
for (const [token, pending] of pendingCodexDiagnosticsConfirmations) {
|
||||
if (now - pending.createdAt >= CODEX_DIAGNOSTICS_CONFIRMATION_TTL_MS) {
|
||||
deletePendingCodexDiagnosticsConfirmation(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deletePendingCodexDiagnosticsConfirmation(token: string): void {
|
||||
const pending = pendingCodexDiagnosticsConfirmations.get(token);
|
||||
pendingCodexDiagnosticsConfirmations.delete(token);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(pending.scopeKey);
|
||||
if (!scopeTokens) {
|
||||
return;
|
||||
}
|
||||
const tokenIndex = scopeTokens.indexOf(token);
|
||||
if (tokenIndex >= 0) {
|
||||
scopeTokens.splice(tokenIndex, 1);
|
||||
}
|
||||
if (scopeTokens.length === 0) {
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.delete(pending.scopeKey);
|
||||
}
|
||||
}
|
||||
|
||||
function deletePendingCodexDiagnosticsConfirmationScope(scopeKey: string): void {
|
||||
const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(scopeKey) ?? [];
|
||||
for (const token of scopeTokens) {
|
||||
pendingCodexDiagnosticsConfirmations.delete(token);
|
||||
}
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.delete(scopeKey);
|
||||
}
|
||||
|
||||
function buildDiagnosticsTags(ctx: PluginCommandContext): Record<string, string> {
|
||||
const tags: Record<string, string> = {
|
||||
source: CODEX_DIAGNOSTICS_SOURCE,
|
||||
};
|
||||
addTag(tags, "channel", ctx.channel);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function addTag(tags: Record<string, string>, key: string, value: unknown): void {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
tags[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodexThreadIdForDisplay(threadId: string): string {
|
||||
return escapeCodexChatText(formatCodexTextForDisplay(threadId));
|
||||
}
|
||||
|
||||
function formatCodexValueForDisplay(value: string): string {
|
||||
return escapeCodexChatText(formatCodexTextForDisplay(value));
|
||||
}
|
||||
|
||||
function formatCodexCopyableValueForDisplay(value: string): string {
|
||||
const safe = formatCodexTextForDisplay(value);
|
||||
if (CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safe)) {
|
||||
return `\`${safe}\``;
|
||||
}
|
||||
return escapeCodexChatText(safe);
|
||||
}
|
||||
|
||||
function formatCodexTextForDisplay(value: string): string {
|
||||
let safe = "";
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
|
||||
}
|
||||
safe = safe.trim();
|
||||
return safe || "<unknown>";
|
||||
}
|
||||
|
||||
function escapeCodexChatText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("@", "\uff20")
|
||||
.replaceAll("`", "\uff40")
|
||||
.replaceAll("[", "\uff3b")
|
||||
.replaceAll("]", "\uff3d")
|
||||
.replaceAll("(", "\uff08")
|
||||
.replaceAll(")", "\uff09")
|
||||
.replaceAll("*", "\u2217")
|
||||
.replaceAll("_", "\uff3f")
|
||||
.replaceAll("~", "\uff5e")
|
||||
.replaceAll("|", "\uff5c");
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsCooldownMs(threadId: string, now: number): number {
|
||||
const lastSentAt = lastCodexDiagnosticsUploadByThread.get(threadId);
|
||||
if (!lastSentAt) {
|
||||
return 0;
|
||||
}
|
||||
const remainingMs = Math.max(0, CODEX_DIAGNOSTICS_COOLDOWN_MS - (now - lastSentAt));
|
||||
if (remainingMs === 0) {
|
||||
lastCodexDiagnosticsUploadByThread.delete(threadId);
|
||||
}
|
||||
return remainingMs;
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsTargetsCooldownMessage(
|
||||
targets: readonly CodexDiagnosticsTarget[],
|
||||
ctx: PluginCommandContext,
|
||||
now: number,
|
||||
options: { includeThreadId?: boolean } = {},
|
||||
): string | undefined {
|
||||
for (const target of targets) {
|
||||
const cooldownMs = readCodexDiagnosticsCooldownMs(target.threadId, now);
|
||||
if (cooldownMs > 0) {
|
||||
if (options.includeThreadId === false) {
|
||||
return `Codex diagnostics were already sent for one of these Codex threads recently. Try again in ${Math.ceil(
|
||||
cooldownMs / 1000,
|
||||
)}s.`;
|
||||
}
|
||||
const displayThreadId = formatCodexThreadIdForDisplay(target.threadId);
|
||||
return `Codex diagnostics were already sent for thread ${displayThreadId} recently. Try again in ${Math.ceil(
|
||||
cooldownMs / 1000,
|
||||
)}s.`;
|
||||
}
|
||||
}
|
||||
const scopeCooldownMs = readCodexDiagnosticsScopeCooldownMs(
|
||||
readCodexDiagnosticsCooldownScope(ctx),
|
||||
now,
|
||||
);
|
||||
if (scopeCooldownMs > 0) {
|
||||
return `Codex diagnostics were already sent for this account or channel recently. Try again in ${Math.ceil(
|
||||
scopeCooldownMs / 1000,
|
||||
)}s.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsScopeCooldownMs(scope: string, now: number): number {
|
||||
const lastSentAt = lastCodexDiagnosticsUploadByScope.get(scope);
|
||||
if (!lastSentAt) {
|
||||
return 0;
|
||||
}
|
||||
const remainingMs = Math.max(0, CODEX_DIAGNOSTICS_COOLDOWN_MS - (now - lastSentAt));
|
||||
if (remainingMs === 0) {
|
||||
lastCodexDiagnosticsUploadByScope.delete(scope);
|
||||
}
|
||||
return remainingMs;
|
||||
}
|
||||
|
||||
function recordCodexDiagnosticsUpload(
|
||||
threadId: string,
|
||||
ctx: PluginCommandContext,
|
||||
now: number,
|
||||
): void {
|
||||
pruneCodexDiagnosticsCooldowns(now);
|
||||
recordBoundedCodexDiagnosticsCooldown(
|
||||
lastCodexDiagnosticsUploadByScope,
|
||||
readCodexDiagnosticsCooldownScope(ctx),
|
||||
CODEX_DIAGNOSTICS_COOLDOWN_MAX_SCOPES,
|
||||
now,
|
||||
);
|
||||
recordBoundedCodexDiagnosticsCooldown(
|
||||
lastCodexDiagnosticsUploadByThread,
|
||||
threadId,
|
||||
CODEX_DIAGNOSTICS_COOLDOWN_MAX_THREADS,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function recordBoundedCodexDiagnosticsCooldown(
|
||||
map: Map<string, number>,
|
||||
key: string,
|
||||
maxSize: number,
|
||||
now: number,
|
||||
): void {
|
||||
if (!map.has(key)) {
|
||||
while (map.size >= maxSize) {
|
||||
const oldestKey = map.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
map.set(key, now);
|
||||
}
|
||||
|
||||
function readCodexDiagnosticsCooldownScope(ctx: PluginCommandContext): string {
|
||||
const scope = readCodexDiagnosticsConfirmationScope(ctx);
|
||||
const payload = JSON.stringify({
|
||||
accountId: scope.accountId ?? null,
|
||||
channelId: scope.channelId ?? null,
|
||||
sessionKey: scope.sessionKey ?? null,
|
||||
messageThreadId: scope.messageThreadId ?? null,
|
||||
threadParentId: scope.threadParentId ?? null,
|
||||
senderId: normalizeCodexDiagnosticsScopeField(ctx.senderId) ?? null,
|
||||
channel: normalizeCodexDiagnosticsScopeField(ctx.channel) ?? "",
|
||||
});
|
||||
return crypto.createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function pruneCodexDiagnosticsCooldowns(now: number): void {
|
||||
pruneCodexDiagnosticsCooldownMap(lastCodexDiagnosticsUploadByThread, now);
|
||||
pruneCodexDiagnosticsCooldownMap(lastCodexDiagnosticsUploadByScope, now);
|
||||
}
|
||||
|
||||
function pruneCodexDiagnosticsCooldownMap(map: Map<string, number>, now: number): void {
|
||||
for (const [key, lastSentAt] of map) {
|
||||
if (now - lastSentAt >= CODEX_DIAGNOSTICS_COOLDOWN_MS) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodexErrorForDisplay(error: string): string {
|
||||
const safe = formatCodexTextForDisplay(error).slice(0, CODEX_DIAGNOSTICS_ERROR_MAX_CHARS);
|
||||
return escapeCodexChatText(safe) || "unknown error";
|
||||
}
|
||||
|
||||
function formatCodexResumeCommandForDisplay(threadId: string): string {
|
||||
const safeThreadId = formatCodexTextForDisplay(threadId);
|
||||
if (!CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safeThreadId)) {
|
||||
return "run codex resume and paste the thread id shown above";
|
||||
}
|
||||
return `\`codex resume ${safeThreadId}\``;
|
||||
}
|
||||
|
||||
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x001f ||
|
||||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x180e ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
|
||||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexDiagnosticsScopeField(value: string | undefined): string | undefined {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `sha256:${crypto.createHash("sha256").update(normalized).digest("hex")}`;
|
||||
}
|
||||
|
||||
async function startThreadAction(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ export function createCodexCommand(options: {
|
||||
return {
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
ownership: "reserved",
|
||||
agentPromptGuidance: [
|
||||
"Native Codex app-server plugin is available (`/codex ...`). For Codex bind/control/thread/resume/steer/stop requests, prefer `/codex bind`, `/codex threads`, `/codex resume`, `/codex steer`, and `/codex stop` over ACP.",
|
||||
"Use ACP for Codex only when the user explicitly asks for ACP/acpx or wants to test the ACP path.",
|
||||
|
||||
Reference in New Issue
Block a user