mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +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.",
|
||||
|
||||
@@ -75,6 +75,10 @@ function buildPendingPayload(params: {
|
||||
approvalId: params.request.id,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
warningText:
|
||||
params.view.approvalKind === "exec"
|
||||
? (params.view.warningText ?? undefined)
|
||||
: undefined,
|
||||
command: params.view.approvalKind === "exec" ? params.view.commandText : "",
|
||||
cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined,
|
||||
host:
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("telegram native approval adapter", () => {
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.telegram.execApprovals.approvers`");
|
||||
expect(text).toContain("`commands.ownerAllowFrom`");
|
||||
expect(text).toContain("`channels.telegram.allowFrom`");
|
||||
expect(text).toContain("`channels.telegram.defaultTo`");
|
||||
expect(text).not.toContain("`channels.telegram.dm.allowFrom`");
|
||||
@@ -52,6 +53,7 @@ describe("telegram native approval adapter", () => {
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`");
|
||||
expect(text).toContain("`commands.ownerAllowFrom`");
|
||||
expect(text).toContain("`channels.telegram.accounts.work.allowFrom`");
|
||||
expect(text).toContain("`channels.telegram.accounts.work.defaultTo`");
|
||||
expect(text).not.toContain("`channels.telegram.allowFrom`");
|
||||
|
||||
@@ -92,7 +92,7 @@ const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalC
|
||||
accountId && accountId !== "default"
|
||||
? `channels.telegram.accounts.${accountId}`
|
||||
: "channels.telegram";
|
||||
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${prefix}.allowFrom\` or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
||||
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`commands.ownerAllowFrom\`, \`${prefix}.allowFrom\`, or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
||||
},
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("native command auth in groups", () => {
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
resolveGroupPolicy?: () => ChannelGroupPolicy;
|
||||
@@ -23,6 +24,7 @@ describe("native command auth in groups", () => {
|
||||
telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig),
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
storeAllowFrom: params.storeAllowFrom,
|
||||
useAccessGroups: params.useAccessGroups ?? false,
|
||||
resolveGroupPolicy:
|
||||
params.resolveGroupPolicy ??
|
||||
@@ -50,6 +52,20 @@ describe("native command auth in groups", () => {
|
||||
expect(notAuthCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not authorize group native commands from the DM allowlist store", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
storeAllowFrom: ["12345"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
|
||||
expect(notAuthCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("authorizes native commands in groups from commands.allowFrom.telegram", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
cfg: {
|
||||
|
||||
@@ -196,18 +196,27 @@ function registerAndResolveStatusHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
|
||||
const {
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
commandName: "status",
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? ["*"],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
storeAllowFrom,
|
||||
useAccessGroups: true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
@@ -219,6 +228,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups: boolean;
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
@@ -231,6 +241,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
@@ -239,7 +250,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const telegramDeps: TelegramNativeCommandDeps = {
|
||||
getRuntimeConfig: vi.fn(() => cfg),
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []),
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
@@ -276,6 +287,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
@@ -288,6 +300,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
@@ -297,6 +310,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? [],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
storeAllowFrom,
|
||||
useAccessGroups: useAccessGroups ?? true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
@@ -787,6 +801,42 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
expect(sessionMetaCall?.ctx?.ChatType).toBe("group");
|
||||
});
|
||||
|
||||
it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => {
|
||||
const { handler, sendMessage } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: ["200"],
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expectUnauthorizedNewCommandBlocked(sendMessage);
|
||||
});
|
||||
|
||||
it("authorizes paired Telegram DMs without marking them as owners", async () => {
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: ["200"],
|
||||
});
|
||||
await handler(createTelegramPrivateCommandContext());
|
||||
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
ctx?: {
|
||||
CommandAuthorized?: boolean;
|
||||
};
|
||||
},
|
||||
]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true);
|
||||
expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom");
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through bound topic sessions", async () => {
|
||||
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
||||
bindingId: "default:-1001234567890:topic:42",
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createNativeCommandsHarness(params?: {
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
nativeEnabled?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
@@ -139,7 +140,7 @@ export function createNativeCommandsHarness(params?: {
|
||||
const log: AnyMock = vi.fn();
|
||||
const telegramDeps = {
|
||||
getRuntimeConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)),
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
readChannelAllowFromStore: vi.fn(async () => params?.storeAllowFrom ?? []),
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
|
||||
@@ -116,6 +116,7 @@ type TelegramCommandAuthResult = {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
commandAuthorized: boolean;
|
||||
senderIsOwner: boolean;
|
||||
};
|
||||
|
||||
let telegramNativeCommandDeliveryRuntimePromise:
|
||||
@@ -414,6 +415,20 @@ async function resolveTelegramCommandAuth(params: {
|
||||
commandAuthorized: false,
|
||||
})
|
||||
: null;
|
||||
const ownerAccess = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
AccountId: accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
const sendAuthMessage = async (text: string) => {
|
||||
await withTelegramApiErrorLogging({
|
||||
@@ -493,6 +508,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
const groupSenderAllowed = isGroup
|
||||
? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername })
|
||||
: false;
|
||||
const ownerAuthorizerConfigured = ownerAccess.senderIsOwner || ownerAccess.ownerList.length > 0;
|
||||
const commandAuthorized = commandsAllowFromConfigured
|
||||
? Boolean(commandsAllowFromAccess?.isAuthorizedSender)
|
||||
: resolveCommandAuthorizedFromAuthorizers({
|
||||
@@ -502,6 +518,10 @@ async function resolveTelegramCommandAuth(params: {
|
||||
...(isGroup
|
||||
? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }]
|
||||
: []),
|
||||
{
|
||||
configured: ownerAuthorizerConfigured,
|
||||
allowed: ownerAccess.senderIsOwner,
|
||||
},
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
@@ -519,6 +539,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
senderIsOwner: ownerAccess.senderIsOwner,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1115,7 +1136,8 @@ export const registerTelegramNativeCommands = ({
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
|
||||
const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } =
|
||||
auth;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
runtimeCfg,
|
||||
@@ -1177,6 +1199,7 @@ export const registerTelegramNativeCommands = ({
|
||||
senderId,
|
||||
channel: "telegram",
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
senderIsOwner,
|
||||
sessionKey: route.sessionKey,
|
||||
commandBody,
|
||||
config: runtimeCfg,
|
||||
|
||||
@@ -135,7 +135,7 @@ export const telegramChannelConfigUiHints = {
|
||||
},
|
||||
"execApprovals.approvers": {
|
||||
label: "Telegram Exec Approval Approvers",
|
||||
help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.",
|
||||
help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom, channels.telegram.allowFrom, and direct-message defaultTo when possible.",
|
||||
},
|
||||
"execApprovals.agentFilter": {
|
||||
label: "Telegram Exec Approval Agent Filter",
|
||||
|
||||
@@ -34,6 +34,7 @@ export function buildTelegramExecApprovalPendingPayload(params: {
|
||||
approvalId: params.request.id,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
warningText: params.request.request.warningText ?? undefined,
|
||||
command: resolveExecApprovalCommandDisplay(params.request.request).commandText,
|
||||
cwd: params.request.request.cwd ?? undefined,
|
||||
host: params.request.request.host === "node" ? "node" : "gateway",
|
||||
|
||||
@@ -110,7 +110,7 @@ function makeForeignChannelApprovalRequest(params: {
|
||||
}
|
||||
|
||||
describe("telegram exec approvals", () => {
|
||||
it("requires explicit enablement even when approvers resolve", () => {
|
||||
it("auto-enables when approvers resolve unless explicitly disabled", () => {
|
||||
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
|
||||
expect(
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
@@ -121,12 +121,12 @@ describe("telegram exec approvals", () => {
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg: buildConfig(undefined, { allowFrom: ["123"] }),
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ approvers: ["123"] }),
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: "auto", approvers: ["123"] }),
|
||||
@@ -146,6 +146,20 @@ describe("telegram exec approvals", () => {
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);
|
||||
});
|
||||
|
||||
it("infers approvers from command owners", () => {
|
||||
const cfg = {
|
||||
...buildConfig(),
|
||||
commands: {
|
||||
ownerAllowFrom: ["telegram:12345", "tg:67890", "discord:ignored", "-100999"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(getTelegramExecApprovalApprovers({ cfg })).toEqual(["12345", "67890"]);
|
||||
expect(isTelegramExecApprovalClientEnabled({ cfg })).toBe(true);
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "12345" })).toBe(true);
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(true);
|
||||
});
|
||||
|
||||
it("infers approvers from allowFrom and direct defaultTo", () => {
|
||||
const cfg = buildConfig(
|
||||
{ enabled: true },
|
||||
|
||||
@@ -35,18 +35,22 @@ function normalizeTelegramDirectApproverId(value: string | number): string | und
|
||||
return chatId;
|
||||
}
|
||||
|
||||
function resolveTelegramOwnerApprovers(cfg: OpenClawConfig): Array<string | number> {
|
||||
const ownerAllowFrom = cfg.commands?.ownerAllowFrom;
|
||||
return Array.isArray(ownerAllowFrom) ? ownerAllowFrom : [];
|
||||
}
|
||||
|
||||
export function resolveTelegramExecApprovalConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): TelegramExecApprovalConfig | undefined {
|
||||
const account = resolveTelegramAccount(params);
|
||||
const config = account.config.execApprovals;
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const enabled =
|
||||
account.enabled && account.tokenSource !== "none" ? (config?.enabled ?? "auto") : false;
|
||||
return {
|
||||
...config,
|
||||
enabled: account.enabled && account.tokenSource !== "none" ? config.enabled : false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +62,7 @@ export function getTelegramExecApprovalApprovers(params: {
|
||||
return resolveApprovalApprovers({
|
||||
explicit: resolveTelegramExecApprovalConfig(params)?.approvers,
|
||||
allowFrom: account.allowFrom,
|
||||
extraAllowFrom: resolveTelegramOwnerApprovers(params.cfg),
|
||||
defaultTo: account.defaultTo ? String(account.defaultTo) : null,
|
||||
normalizeApprover: normalizeTelegramDirectApproverId,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user