mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 12:20:45 +00:00
* fix(scripts): find codex protocol source from worktrees * fix(test): keep codex harness docker caches writable * fix(test): relax live codex cache mount permissions * test(codex): add live docker harness debug output * fix(test): detect numeric ci env in codex docker harness * fix(codex): skip duplicate agent-command telemetry * fix(tooling): skip sparse-missing oxlint tsconfig * fix(tooling): route changed checks through testbox * fix(qa): keep coverage json source-clean * fix(test): preflight codex docker auth * fix(codex): validate bind option values * fix(codex): parse quoted command arguments * fix(codex): reject extra control args * fix(codex): use content for blank bound prompts * fix(codex): decode local image file urls * fix(codex): treat local media urls as images * fix(codex): keep windows media paths local * fix(codex): reject malformed diagnostics confirmations * fix(codex): reject malformed resume commands * fix(codex): reject malformed thread actions * fix(codex): reject malformed turn controls * fix(codex): reject malformed model controls * fix(codex): resolve empty user input prompts * fix(codex): enforce user input options * fix(codex): reject ambiguous computer-use actions * fix(codex): ignore stale bound turn notifications * test(gateway): close task registries in gateway harness * test(gateway): route cleanup through task seams * fix(codex): describe current permission approvals * fix(codex): disclose command approval amendments * fix(codex): preserve approval detail under truncation * fix(codex): propagate dynamic tool failures * test(codex): align dynamic tool block contract * fix(codex): reject extra read-only command operands * fix(codex): escape command readout fields * fix(codex): escape status probe errors * fix(codex): narrow formatted thread details * fix(codex): escape successful status summaries * fix(codex): escape bound control replies * fix(codex): escape user input prompts * fix(codex): escape control failure replies * fix(codex): escape approval prompt text * test(codex): narrow escaped reply assertions * test(codex): complete strict reply fixtures * test(codex): preserve account fixture literals * test(codex): align status probe fixtures * fix(codex): satisfy sanitizer regex lint * fix(codex): harden command readouts * fix(codex): harden bound image inputs * fix(codex): sanitize command failure replies * test(codex): complete rate limit fixture * test(tooling): isolate postinstall compile cache fixture * fix(codex): keep app-server event ownership explicit --------- Co-authored-by: pashpashpash <nik@vault77.ai>
317 lines
9.1 KiB
TypeScript
317 lines
9.1 KiB
TypeScript
import {
|
|
embeddedAgentLog,
|
|
type EmbeddedRunAttemptParams,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { formatCodexDisplayText } from "../command-formatters.js";
|
|
import {
|
|
isJsonObject,
|
|
type CodexServerNotification,
|
|
type JsonObject,
|
|
type JsonValue,
|
|
} from "./protocol.js";
|
|
|
|
type PendingUserInput = {
|
|
requestId: number | string;
|
|
threadId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
questions: UserInputQuestion[];
|
|
resolve: (value: JsonValue) => void;
|
|
cleanup: () => void;
|
|
};
|
|
|
|
type UserInputQuestion = {
|
|
id: string;
|
|
header: string;
|
|
question: string;
|
|
isOther: boolean;
|
|
isSecret: boolean;
|
|
options: UserInputOption[] | null;
|
|
};
|
|
|
|
type UserInputOption = {
|
|
label: string;
|
|
description: string;
|
|
};
|
|
|
|
type CodexUserInputBridge = {
|
|
handleRequest: (request: {
|
|
id: number | string;
|
|
params?: JsonValue;
|
|
}) => Promise<JsonValue | undefined>;
|
|
handleQueuedMessage: (text: string) => boolean;
|
|
handleNotification: (notification: CodexServerNotification) => void;
|
|
cancelPending: () => void;
|
|
};
|
|
|
|
export function createCodexUserInputBridge(params: {
|
|
paramsForRun: EmbeddedRunAttemptParams;
|
|
threadId: string;
|
|
turnId: string;
|
|
signal?: AbortSignal;
|
|
}): CodexUserInputBridge {
|
|
let pending: PendingUserInput | undefined;
|
|
|
|
const resolvePending = (value: JsonValue) => {
|
|
const current = pending;
|
|
if (!current) {
|
|
return;
|
|
}
|
|
pending = undefined;
|
|
current.cleanup();
|
|
current.resolve(value);
|
|
};
|
|
|
|
return {
|
|
async handleRequest(request) {
|
|
const requestParams = readUserInputParams(request.params);
|
|
if (!requestParams) {
|
|
return undefined;
|
|
}
|
|
if (requestParams.threadId !== params.threadId || requestParams.turnId !== params.turnId) {
|
|
return undefined;
|
|
}
|
|
if (requestParams.questions.length === 0) {
|
|
return emptyUserInputResponse();
|
|
}
|
|
|
|
resolvePending(emptyUserInputResponse());
|
|
|
|
return new Promise<JsonValue>((resolve) => {
|
|
const abortListener = () => resolvePending(emptyUserInputResponse());
|
|
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
|
|
pending = {
|
|
requestId: request.id,
|
|
threadId: requestParams.threadId,
|
|
turnId: requestParams.turnId,
|
|
itemId: requestParams.itemId,
|
|
questions: requestParams.questions,
|
|
resolve,
|
|
cleanup,
|
|
};
|
|
params.signal?.addEventListener("abort", abortListener, { once: true });
|
|
if (params.signal?.aborted) {
|
|
resolvePending(emptyUserInputResponse());
|
|
return;
|
|
}
|
|
void deliverUserInputPrompt(params.paramsForRun, requestParams.questions).catch((error) => {
|
|
embeddedAgentLog.warn("failed to deliver codex user input prompt", { error });
|
|
});
|
|
});
|
|
},
|
|
handleQueuedMessage(text) {
|
|
const current = pending;
|
|
if (!current) {
|
|
return false;
|
|
}
|
|
resolvePending(buildUserInputResponse(current.questions, text));
|
|
return true;
|
|
},
|
|
handleNotification(notification) {
|
|
if (notification.method !== "serverRequest/resolved" || !pending) {
|
|
return;
|
|
}
|
|
const notificationParams = isJsonObject(notification.params)
|
|
? notification.params
|
|
: undefined;
|
|
const requestId = notificationParams ? readRequestId(notificationParams) : undefined;
|
|
if (
|
|
notificationParams &&
|
|
readString(notificationParams, "threadId") === pending.threadId &&
|
|
requestId !== undefined &&
|
|
String(requestId) === String(pending.requestId)
|
|
) {
|
|
resolvePending(emptyUserInputResponse());
|
|
}
|
|
},
|
|
cancelPending() {
|
|
resolvePending(emptyUserInputResponse());
|
|
},
|
|
};
|
|
}
|
|
|
|
function readUserInputParams(value: JsonValue | undefined):
|
|
| {
|
|
threadId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
questions: UserInputQuestion[];
|
|
}
|
|
| undefined {
|
|
if (!isJsonObject(value)) {
|
|
return undefined;
|
|
}
|
|
const threadId = readString(value, "threadId");
|
|
const turnId = readString(value, "turnId");
|
|
const itemId = readString(value, "itemId");
|
|
const questionsRaw = value.questions;
|
|
if (!threadId || !turnId || !itemId || !Array.isArray(questionsRaw)) {
|
|
return undefined;
|
|
}
|
|
const questions = questionsRaw
|
|
.map(readQuestion)
|
|
.filter((question): question is UserInputQuestion => Boolean(question));
|
|
return { threadId, turnId, itemId, questions };
|
|
}
|
|
|
|
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
|
if (!isJsonObject(value)) {
|
|
return undefined;
|
|
}
|
|
const id = readString(value, "id");
|
|
const header = readString(value, "header");
|
|
const question = readString(value, "question");
|
|
if (!id || !header || !question) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
id,
|
|
header,
|
|
question,
|
|
isOther: value.isOther === true,
|
|
isSecret: value.isSecret === true,
|
|
options: readOptions(value.options),
|
|
};
|
|
}
|
|
|
|
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
|
|
if (!Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const options = value
|
|
.map(readOption)
|
|
.filter((option): option is UserInputOption => Boolean(option));
|
|
return options.length > 0 ? options : null;
|
|
}
|
|
|
|
function readOption(value: JsonValue): UserInputOption | undefined {
|
|
if (!isJsonObject(value)) {
|
|
return undefined;
|
|
}
|
|
const label = readString(value, "label");
|
|
const description = readString(value, "description") ?? "";
|
|
return label ? { label, description } : undefined;
|
|
}
|
|
|
|
async function deliverUserInputPrompt(
|
|
params: EmbeddedRunAttemptParams,
|
|
questions: UserInputQuestion[],
|
|
): Promise<void> {
|
|
const text = formatUserInputPrompt(questions);
|
|
if (params.onBlockReply) {
|
|
await params.onBlockReply({ text });
|
|
return;
|
|
}
|
|
await params.onPartialReply?.({ text });
|
|
}
|
|
|
|
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
|
|
const lines = ["Codex needs input:"];
|
|
questions.forEach((question, index) => {
|
|
if (questions.length > 1) {
|
|
lines.push(
|
|
"",
|
|
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
|
|
formatCodexDisplayText(question.question),
|
|
);
|
|
} else {
|
|
lines.push(
|
|
"",
|
|
formatCodexDisplayText(question.header),
|
|
formatCodexDisplayText(question.question),
|
|
);
|
|
}
|
|
if (question.isSecret) {
|
|
lines.push("This channel may show your reply to other participants.");
|
|
}
|
|
question.options?.forEach((option, optionIndex) => {
|
|
lines.push(
|
|
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
|
|
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
|
|
}`,
|
|
);
|
|
});
|
|
if (question.isOther) {
|
|
lines.push("Other: reply with your own answer.");
|
|
}
|
|
});
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
|
|
const answers: JsonObject = {};
|
|
if (questions.length === 1) {
|
|
const question = questions[0];
|
|
if (question) {
|
|
const answer = normalizeAnswer(inputText, question);
|
|
answers[question.id] = { answers: answer ? [answer] : [] };
|
|
}
|
|
return { answers };
|
|
}
|
|
|
|
const keyed = parseKeyedAnswers(inputText);
|
|
const fallbackLines = inputText
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
questions.forEach((question, index) => {
|
|
const key =
|
|
keyed.get(question.id.toLowerCase()) ??
|
|
keyed.get(question.header.toLowerCase()) ??
|
|
keyed.get(question.question.toLowerCase()) ??
|
|
keyed.get(String(index + 1));
|
|
const answer = key ?? fallbackLines[index] ?? "";
|
|
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
|
|
answers[question.id] = { answers: normalized ? [normalized] : [] };
|
|
});
|
|
return { answers };
|
|
}
|
|
|
|
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
|
|
const trimmed = answer.trim();
|
|
const options = question.options ?? [];
|
|
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
|
|
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
|
|
if (indexed) {
|
|
return indexed.label;
|
|
}
|
|
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
|
|
if (exact) {
|
|
return exact.label;
|
|
}
|
|
if (options.length > 0 && !question.isOther) {
|
|
return undefined;
|
|
}
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
function parseKeyedAnswers(inputText: string): Map<string, string> {
|
|
const answers = new Map<string, string>();
|
|
for (const line of inputText.split(/\r?\n/)) {
|
|
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const key = match[1]?.trim().toLowerCase();
|
|
const value = match[2]?.trim();
|
|
if (key && value) {
|
|
answers.set(key, value);
|
|
}
|
|
}
|
|
return answers;
|
|
}
|
|
|
|
function emptyUserInputResponse(): JsonObject {
|
|
return { answers: {} };
|
|
}
|
|
|
|
function readString(record: JsonObject, key: string): string | undefined {
|
|
const value = record[key];
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function readRequestId(record: JsonObject): string | number | undefined {
|
|
const value = record.requestId;
|
|
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
|
}
|