Files
openclaw/src/gateway/gateway-codex-bind.live.test.ts
2026-05-08 23:30:20 +01:00

581 lines
20 KiB
TypeScript

import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import type { ChannelOutboundContext } from "../channels/plugins/types.public.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { resolveBundledPluginWorkspaceSourcePath } from "../plugins/bundled-plugin-metadata.js";
import { pluginCommands } from "../plugins/command-registry-state.js";
import { clearPluginLoaderCache } from "../plugins/loader.js";
import {
pinActivePluginChannelRegistry,
releasePinnedPluginChannelRegistry,
resetPluginRuntimeStateForTest,
} from "../plugins/runtime.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { sleep } from "../utils.js";
import type { GatewayClient } from "./client.js";
import { connectTestGatewayClient } from "./gateway-cli-backend.live-helpers.js";
import { renderCatFacePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE = isLiveTestEnabled();
const CODEX_BIND_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CODEX_BIND);
const describeLive = LIVE && CODEX_BIND_LIVE ? describe : describe.skip;
const CODEX_BIND_TIMEOUT_MS = 10 * 60_000;
const CODEX_BIND_REQUEST_TIMEOUT_MS = 180_000;
const DEFAULT_CODEX_BIND_MODEL = "gpt-5.4";
type CapturedOutboundReply = {
accountId?: string;
text: string;
threadId?: string | number;
to: string;
};
function createSlackCurrentConversationBindingRegistry(outboundReplies: CapturedOutboundReply[]) {
return createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: {
id: "slack",
meta: {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
blurb: "test stub.",
aliases: [],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
},
outbound: {
deliveryMode: "direct",
sendText: async ({ accountId, text, threadId, to }: ChannelOutboundContext) => {
outboundReplies.push({
...(accountId ? { accountId } : {}),
text,
...(threadId != null ? { threadId } : {}),
to,
});
return { channel: "slack", messageId: `slack-${outboundReplies.length}` };
},
},
bindings: {
compileConfiguredBinding: () => null,
matchInboundConversation: () => null,
resolveCommandConversation: ({
commandTo,
originatingTo,
fallbackTo,
}: {
commandTo?: string;
originatingTo?: string;
fallbackTo?: string;
}) => {
const conversationId = [commandTo, originatingTo, fallbackTo].find(Boolean)?.trim();
return conversationId ? { conversationId } : null;
},
},
},
},
]);
}
async function getFreeGatewayPort(): Promise<number> {
const { getFreePortBlockWithPermissionFallback } = await import("../test-utils/ports.js");
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
fallbackBase: 42_000,
});
}
function extractAssistantTexts(messages: unknown[]): string[] {
const texts: string[] = [];
for (const entry of messages) {
if (!entry || typeof entry !== "object") {
continue;
}
if ((entry as { role?: unknown }).role !== "assistant") {
continue;
}
const text = extractFirstTextBlock(entry);
if (typeof text === "string" && text.trim().length > 0) {
texts.push(text);
}
}
return texts;
}
function formatAssistantTextPreview(texts: string[], maxChars = 800): string {
const combined = texts.join("\n\n").trim();
if (!combined) {
return "<empty>";
}
return combined.length <= maxChars ? combined : combined.slice(-maxChars);
}
async function waitForOutboundText(params: {
replies: CapturedOutboundReply[];
contains: string;
minReplyCount?: number;
timeoutMs?: number;
}): Promise<{ outboundTexts: string[]; matchedText: string }> {
const timeoutMs = params.timeoutMs ?? 60_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const outboundTexts: string[] = [];
for (const reply of params.replies) {
if (reply.text.trim().length > 0) {
outboundTexts.push(reply.text);
}
}
const minReplyCount = params.minReplyCount ?? 1;
const matchedText = outboundTexts
.slice(Math.max(0, minReplyCount - 1))
.find((text) => text.includes(params.contains));
if (outboundTexts.length >= minReplyCount && matchedText) {
return { outboundTexts, matchedText };
}
await sleep(500);
}
throw new Error(
`timed out waiting for outbound text containing ${params.contains}: ${formatAssistantTextPreview(
params.replies.map((reply) => reply.text),
)}`,
);
}
function restoreEnvVar(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
async function waitForAgentRunOk(client: GatewayClient, runId: string): Promise<void> {
const result: { status?: string } = await client.request(
"agent.wait",
{ runId, timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS },
{ timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS + 5_000 },
);
if (result?.status !== "ok") {
throw new Error(`agent.wait failed for ${runId}: status=${String(result?.status)}`);
}
}
async function sendChatAndWait(params: {
client: GatewayClient;
sessionKey: string;
idempotencyKey: string;
message: string;
originatingChannel: string;
originatingTo: string;
originatingAccountId: string;
deliver?: boolean;
attachments?: Array<{
mimeType: string;
fileName: string;
content: string;
}>;
}): Promise<void> {
const started: { runId?: string; status?: string } = await params.client.request("chat.send", {
sessionKey: params.sessionKey,
message: params.message,
idempotencyKey: params.idempotencyKey,
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
deliver: params.deliver,
attachments: params.attachments,
});
if (started?.status !== "started" || typeof started.runId !== "string") {
throw new Error(`chat.send did not start correctly: ${JSON.stringify(started)}`);
}
await waitForAgentRunOk(params.client, started.runId);
}
async function waitForAssistantText(params: {
client: GatewayClient;
sessionKey: string;
contains: string;
caseInsensitive?: boolean;
minAssistantCount?: number;
timeoutMs?: number;
}): Promise<{ messages: unknown[]; assistantTexts: string[]; matchedAssistantText: string }> {
const timeoutMs = params.timeoutMs ?? 60_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const history: { messages?: unknown[] } = await params.client.request("chat.history", {
sessionKey: params.sessionKey,
limit: 24,
});
const messages = history.messages ?? [];
const assistantTexts = extractAssistantTexts(messages);
const minAssistantCount = params.minAssistantCount ?? 1;
const expected = params.caseInsensitive ? params.contains.toLowerCase() : params.contains;
const matchedAssistantText = assistantTexts
.slice(Math.max(0, minAssistantCount - 1))
.find((text) => (params.caseInsensitive ? text.toLowerCase() : text).includes(expected));
if (assistantTexts.length >= minAssistantCount && matchedAssistantText) {
return { messages, assistantTexts, matchedAssistantText };
}
await sleep(500);
}
const finalHistory: { messages?: unknown[] } = await params.client.request("chat.history", {
sessionKey: params.sessionKey,
limit: 24,
});
throw new Error(
`timed out waiting for assistant text containing ${params.contains}: ${formatAssistantTextPreview(
extractAssistantTexts(finalHistory.messages ?? []),
)}`,
);
}
function resolveCodexPluginRoot(): string {
const command =
pluginCommands.get("/codex") ??
Array.from(pluginCommands.values()).find((candidate) => candidate.pluginId === "codex");
if (command?.pluginRoot) {
return command.pluginRoot;
}
const pluginRoot = resolveBundledPluginWorkspaceSourcePath({
rootDir: process.cwd(),
pluginId: "codex",
});
if (!pluginRoot) {
throw new Error("Codex bundled plugin root was not found");
}
return pluginRoot;
}
function resolveBoundSessionKey(params: {
channel: string;
accountId: string;
conversationId: string;
}): string {
const binding = getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
});
if (!binding?.targetSessionKey) {
throw new Error(
`No plugin binding target session for ${params.channel}:${params.conversationId}`,
);
}
return binding.targetSessionKey;
}
async function writePluginBindingApproval(params: {
homeDir: string;
pluginRoot: string;
channel: string;
accountId: string;
}): Promise<void> {
const openclawDir = path.join(params.homeDir, ".openclaw");
await fs.mkdir(openclawDir, { recursive: true });
await fs.writeFile(
path.join(openclawDir, "plugin-binding-approvals.json"),
`${JSON.stringify(
{
version: 1,
approvals: [
{
pluginRoot: params.pluginRoot,
pluginId: "codex",
pluginName: "Codex",
channel: params.channel,
accountId: params.accountId,
approvedAt: Date.now(),
},
],
},
null,
2,
)}\n`,
);
}
async function writeGatewayConfig(params: {
configPath: string;
model: string;
port: number;
token: string;
workspace: string;
}): Promise<void> {
const cfg: OpenClawConfig = {
gateway: {
mode: "local",
port: params.port,
auth: { mode: "token", token: params.token },
},
plugins: {
allow: ["codex"],
entries: {
codex: {
enabled: true,
config: {
appServer: {
mode: "yolo",
requestTimeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
defaultWorkspaceDir: params.workspace,
},
},
},
},
},
agents: {
defaults: {
workspace: params.workspace,
agentRuntime: { id: "codex" },
model: { primary: `codex/${params.model}` },
skipBootstrap: true,
heartbeat: { every: "0m" },
sandbox: { mode: "off" },
},
},
};
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
describeLive("gateway live (native Codex conversation binding)", () => {
it(
"binds a Slack DM to Codex app-server, updates controls, and forwards image media paths",
async () => {
const previous = {
codexHome: process.env.CODEX_HOME,
configPath: process.env.OPENCLAW_CONFIG_PATH,
gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN,
home: process.env.HOME,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-codex-bind-"));
const tempHome = path.join(tempRoot, "home");
const stateDir = path.join(tempRoot, "state");
const workspace = path.join(tempRoot, "workspace");
const configPath = path.join(tempRoot, "openclaw.json");
const token = `test-${randomUUID()}`;
const port = await getFreeGatewayPort();
const sessionKey = "main";
const accountId = "default";
const slackUserId = `U${randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase()}`;
const conversationId = `user:${slackUserId}`;
const bindModel =
process.env.OPENCLAW_LIVE_CODEX_BIND_MODEL?.trim() || DEFAULT_CODEX_BIND_MODEL;
const outboundReplies: CapturedOutboundReply[] = [];
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "AGENTS.md"),
[
"# AGENTS.md",
"",
"Follow exact reply instructions from the user.",
"Do not add commentary when asked for an exact response.",
].join("\n"),
);
await fs.mkdir(tempHome, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
await writeGatewayConfig({ configPath, model: bindModel, port, token, workspace });
clearConfigCache();
clearRuntimeConfigSnapshot();
clearPluginLoaderCache();
resetPluginRuntimeStateForTest();
const codexHome =
previous.codexHome || (previous.home ? path.join(previous.home, ".codex") : "");
if (codexHome) {
process.env.CODEX_HOME = codexHome;
} else {
delete process.env.CODEX_HOME;
}
process.env.HOME = tempHome;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_STATE_DIR = stateDir;
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectTestGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
timeoutMs: 90_000,
requestTimeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
clientDisplayName: "vitest-codex-bind-live",
});
const channelRegistry = createSlackCurrentConversationBindingRegistry(outboundReplies);
pinActivePluginChannelRegistry(channelRegistry);
try {
await writePluginBindingApproval({
homeDir: tempHome,
pluginRoot: resolveCodexPluginRoot(),
channel: "slack",
accountId,
});
await sendChatAndWait({
client,
sessionKey,
idempotencyKey: `idem-codex-bind-${randomUUID()}`,
message: `/codex bind --cwd ${workspace} --model ${bindModel}`,
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
deliver: true,
});
const bindReply = await waitForOutboundText({
replies: outboundReplies,
contains: "Bound this conversation to Codex thread",
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
expect(bindReply.matchedText).toContain("Bound this conversation to Codex thread");
const boundSessionKey = resolveBoundSessionKey({
channel: "slack",
accountId,
conversationId,
});
let commandReplyCount = bindReply.outboundTexts.length;
const sendCodexCommand = async (message: string, contains: string, timeoutMs = 60_000) => {
await sendChatAndWait({
client,
sessionKey,
idempotencyKey: `idem-codex-command-${randomUUID()}`,
message,
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
deliver: true,
});
const result = await waitForOutboundText({
replies: outboundReplies,
contains,
minReplyCount: commandReplyCount + 1,
timeoutMs,
});
commandReplyCount = result.outboundTexts.length;
return result;
};
await sendCodexCommand(
"/codex status",
"Codex app-server: connected",
CODEX_BIND_REQUEST_TIMEOUT_MS,
);
await sendCodexCommand("/codex models", "Codex models:", CODEX_BIND_REQUEST_TIMEOUT_MS);
await sendCodexCommand("/codex fast on", "Codex fast mode enabled.");
await sendCodexCommand("/codex fast status", "Codex fast mode: on.");
await sendCodexCommand("/codex permissions default", "Codex permissions set to default.");
await sendCodexCommand("/codex permissions status", "Codex permissions: default.");
await sendCodexCommand("/codex model", `Codex model: ${bindModel}`);
await sendCodexCommand("/codex stop", "No active Codex run to stop.");
const bindingStatus = await sendCodexCommand("/codex binding", "- Fast: on");
if (!bindingStatus.matchedText.includes("- Permissions: default")) {
throw new Error(
`binding status did not include default permissions: ${bindingStatus.matchedText}`,
);
}
const textNonce = randomBytes(4).toString("hex").toUpperCase();
const textToken = `CODEX-BIND-${textNonce}`;
await sendChatAndWait({
client,
sessionKey,
idempotencyKey: `idem-codex-bound-text-${randomUUID()}`,
message: `Reply with exactly this token and nothing else: ${textToken}`,
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
});
const textHistory = await waitForAssistantText({
client,
sessionKey: boundSessionKey,
contains: textToken,
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
expect(textHistory.matchedAssistantText).toContain(textToken);
await sendChatAndWait({
client,
sessionKey,
idempotencyKey: `idem-codex-bound-image-${randomUUID()}`,
message:
"What animal is drawn in the attached image? Reply with only the lowercase animal name.",
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
attachments: [
{
mimeType: "image/png",
fileName: `codex-bind-probe-${randomUUID()}.png`,
content: renderCatFacePngBase64(),
},
],
});
const imageHistory = await waitForAssistantText({
client,
sessionKey: boundSessionKey,
contains: "cat",
caseInsensitive: true,
minAssistantCount: textHistory.assistantTexts.length + 1,
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
expect(imageHistory.matchedAssistantText.toLowerCase()).toContain("cat");
await sendCodexCommand("/codex detach", "Detached this conversation from Codex.");
await sendCodexCommand("/codex binding", "No Codex conversation binding is attached.");
} finally {
releasePinnedPluginChannelRegistry(channelRegistry);
clearConfigCache();
clearRuntimeConfigSnapshot();
await client.stopAndWait({ timeoutMs: 2_000 }).catch(() => {});
await server.close();
await fs.rm(tempRoot, { recursive: true, force: true });
restoreEnvVar("CODEX_HOME", previous.codexHome);
restoreEnvVar("OPENCLAW_CONFIG_PATH", previous.configPath);
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", previous.gatewayToken);
restoreEnvVar("HOME", previous.home);
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", previous.skipCanvas);
restoreEnvVar("OPENCLAW_SKIP_CHANNELS", previous.skipChannels);
restoreEnvVar("OPENCLAW_SKIP_CRON", previous.skipCron);
restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", previous.skipGmail);
restoreEnvVar("OPENCLAW_STATE_DIR", previous.stateDir);
}
},
CODEX_BIND_TIMEOUT_MS,
);
});