Files
openclaw/extensions/codex/src/app-server/user-input-bridge.test.ts
Vincent Koc ac3cd1a0ca Harden Codex harness control surfaces (#77459)
* 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>
2026-05-05 07:23:41 +09:00

242 lines
7.0 KiB
TypeScript

import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import { createCodexUserInputBridge } from "./user-input-bridge.js";
function createParams(): EmbeddedRunAttemptParams {
return {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
onBlockReply: vi.fn(),
} as unknown as EmbeddedRunAttemptParams;
}
describe("Codex app-server user input bridge", () => {
it("prompts the originating chat and resolves request_user_input from the next queued message", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const response = bridge.handleRequest({
id: "input-1",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [
{
id: "choice",
header: "Mode",
question: "Pick a mode",
isOther: false,
isSecret: false,
options: [
{ label: "Fast", description: "Use less reasoning" },
{ label: "Deep", description: "Use more reasoning" },
],
},
],
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(params.onBlockReply).toHaveBeenCalledWith({
text: expect.stringContaining("Pick a mode"),
});
expect(bridge.handleQueuedMessage("2")).toBe(true);
await expect(response).resolves.toEqual({
answers: { choice: { answers: ["Deep"] } },
});
});
it("maps keyed multi-question replies to Codex answer ids", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const response = bridge.handleRequest({
id: "input-2",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [
{
id: "repo",
header: "Repository",
question: "Which repo?",
isOther: true,
isSecret: false,
options: null,
},
{
id: "scope",
header: "Scope",
question: "Which scope?",
isOther: false,
isSecret: false,
options: [{ label: "Tests", description: "Only tests" }],
},
],
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(bridge.handleQueuedMessage("repo: openclaw\nscope: Tests")).toBe(true);
await expect(response).resolves.toEqual({
answers: {
repo: { answers: ["openclaw"] },
scope: { answers: ["Tests"] },
},
});
});
it("rejects free-form option replies when Other is disabled", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const response = bridge.handleRequest({
id: "input-options",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [
{
id: "mode",
header: "Mode",
question: "Pick a mode",
isOther: false,
isSecret: false,
options: [{ label: "Fast", description: "Use less reasoning" }],
},
],
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(bridge.handleQueuedMessage("banana")).toBe(true);
await expect(response).resolves.toEqual({
answers: { mode: { answers: [] } },
});
});
it("escapes prompt question and option text before chat display", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const response = bridge.handleRequest({
id: "input-escaped",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [
{
id: "mode",
header: "Mode <@U123>",
question: "Pick [trusted](https://evil) @here",
isOther: false,
isSecret: false,
options: [{ label: "Fast <@U123>", description: "Use [less](https://evil)" }],
},
],
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
const payload = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0];
expect(payload).toEqual(expect.objectContaining({ text: expect.any(String) }));
const text = payload?.text ?? "";
expect(text).toContain("Mode &lt;\uff20U123&gt;");
expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here");
expect(text).toContain(
"Fast &lt;\uff20U123&gt; - Use \uff3bless\uff3d\uff08https://evil\uff09",
);
expect(text).not.toContain("<@U123>");
expect(text).not.toContain("[trusted](https://evil)");
expect(text).not.toContain("@here");
expect(bridge.handleQueuedMessage("1")).toBe(true);
await expect(response).resolves.toEqual({
answers: { mode: { answers: ["Fast <@U123>"] } },
});
});
it("clears pending prompts when Codex resolves the server request itself", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const response = bridge.handleRequest({
id: "input-3",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [
{
id: "answer",
header: "Answer",
question: "Continue?",
isOther: true,
isSecret: false,
options: null,
},
],
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
bridge.handleNotification({
method: "serverRequest/resolved",
params: { threadId: "thread-1", requestId: "input-3" },
});
await expect(response).resolves.toEqual({ answers: {} });
expect(bridge.handleQueuedMessage("too late")).toBe(false);
});
it("resolves malformed empty question prompts without waiting for chat input", async () => {
const params = createParams();
const bridge = createCodexUserInputBridge({
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
await expect(
bridge.handleRequest({
id: "input-empty",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "tool-1",
questions: [],
},
}),
).resolves.toEqual({ answers: {} });
expect(params.onBlockReply).not.toHaveBeenCalled();
expect(bridge.handleQueuedMessage("late answer")).toBe(false);
});
});