Files
openclaw/extensions/codex/src/session-catalog-terminal.ts
Peter Steinberger 191d2313a8 test(codex): split the session-catalog suite along module seams (#124397)
* test(codex): split the session-catalog suite along module seams

Replace the 4.8k-line grandfathered session-catalog.test.ts with seven
seam-anchored suites plus shared fixtures, and remove its max-lines
suppression and baseline entry. The broad openclaw/plugin-sdk/node-host
vi.mock becomes a narrow session-catalog-pty.runtime.ts boundary so the
non-isolated extension-codex worker no longer re-instantiates the
plugin-sdk graph once per split file (the crash PR #124178 measured).
Test bodies are verbatim; AST parity across the split checked out
96/96 identical.

AI-assisted (Codex worker under maintainer review).

* test(codex): carry caller origin in the cron-authority capability fixture

PR #118579 widened the in-process CronCreatorAuthorityCapability run
scope with a contractually required callerOrigin, and the new transcript
tool wiring reads it during turn startup. The hand-built fixture in
run-attempt.configured-mcp.test.ts predated the field, so
bindActiveOperatorTurnAuthority threw and runCodexAppServerAttempt
rejected while five tests awaited turn/start - 120s timeouts, then a
worker teardown crash. #118579's CI never ran the extension-codex lane
(cross-lane classification gap), so main's codex lane was latently red.
Bisected to 8668aeb9698; fixture now mints the local-operator origin the
helper's name promises.

AI-assisted (maintainer-diagnosed, Codex-era fixture repair).

* test(codex): keep catalog fixture internals private
2026-08-15 21:26:13 -07:00

335 lines
11 KiB
TypeScript

// Codex catalog terminal ownership: validated resume commands and terminal plans.
import { resolveAgentDir, resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { decodeNodePtyResumeParams } from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog";
import { resolveCodexAppServerLocalHomeDir } from "./app-server/auth-start-options.js";
import { resolveCodexSupervisionAppServerRuntimeOptions } from "./app-server/config.js";
import type { CodexCatalogHome } from "./session-catalog-homes.js";
import {
CatalogParamsError,
CODEX_APP_SERVER_THREADS_CAPABILITY,
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_LOCAL_SESSION_HOST_ID,
CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
isInteractiveThreadSource,
MAX_ACTION_CATALOG_PAGES,
NODE_INVOKE_TIMEOUT_MS,
unwrapNodeInvokePayload,
} from "./session-catalog-parsing.js";
import { resolveNodeHostExecutable, runNodePtyCommand } from "./session-catalog-pty.runtime.js";
import type {
CodexSessionCatalogControl,
CodexSessionCatalogPage,
CodexSessionCatalogSession,
} from "./session-catalog-types.js";
export const CODEX_TERMINAL_RESUME_COMMAND = "codex.terminal.resume.v1";
export type CodexTerminalConfigSources = {
getPluginConfig: () => unknown;
getRuntimeConfig: () => OpenClawConfig | undefined;
};
function resolveCodexCatalogTerminalHome(
sources: CodexTerminalConfigSources & { agentId?: string; source?: CodexCatalogHome },
): string {
const runtimeConfig = sources.getRuntimeConfig();
if (!runtimeConfig) {
throw new Error("OpenClaw runtime config is unavailable");
}
const agentDir =
sources.source?.agentDir ??
(sources.agentId
? resolveAgentDir(runtimeConfig, sources.agentId)
: resolveDefaultAgentDir(runtimeConfig));
const startOptions =
sources.source?.appServer.start ??
resolveCodexSupervisionAppServerRuntimeOptions({
pluginConfig: sources.getPluginConfig(),
}).start;
return resolveCodexAppServerLocalHomeDir(startOptions, agentDir);
}
export function resolveLocalCodexTerminalExecutable(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return resolveLocalCodexTerminalResolution(env)?.executable;
}
function resolveLocalCodexTerminalResolution(env: NodeJS.ProcessEnv = process.env) {
return resolveNodeHostExecutable("codex", {
env,
pathEnv: env.PATH ?? env.Path ?? "",
strategy: "fallback",
});
}
export function codexNodeTerminalCapability(node: {
connected?: boolean;
commands?: string[];
invocableCommands?: string[];
}): { canOpenTerminalCodex?: true } {
const commands = node.invocableCommands ?? node.commands;
return node.connected === true && commands?.includes(CODEX_TERMINAL_RESUME_COMMAND) === true
? { canOpenTerminalCodex: true }
: {};
}
export async function requireCatalogEligibleThread(
control: CodexSessionCatalogControl,
threadId: string,
): Promise<CodexSessionCatalogSession> {
// Mutating actions use a fresh pinned control and authoritative thread/read. Passive positive hits
// may use the cadence-safe page memo; only a miss must bypass it before rejecting a new thread.
const cached = await findCatalogEligibleThread(control, threadId, false);
if (cached) {
return cached;
}
const refreshed = await findCatalogEligibleThread(control, threadId, true);
if (refreshed) {
return refreshed;
}
throw new CatalogParamsError("Codex session is not a non-archived interactive Codex session");
}
async function findCatalogEligibleThread(
control: CodexSessionCatalogControl,
threadId: string,
forceRefresh: boolean,
): Promise<CodexSessionCatalogSession | undefined> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) {
const page = await control.listPage({
limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
...(forceRefresh ? { forceRefresh: true } : {}),
});
const candidate = page.sessions.find((session) => session.threadId === threadId);
if (candidate) {
if (isInteractiveThreadSource(candidate.source)) {
return candidate;
}
throw new CatalogParamsError("Codex session is not a non-archived interactive Codex session");
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor) {
return undefined;
}
if (seenCursors.has(nextCursor)) {
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
export function createCodexTerminalNodeHostCommand(
bindRequest: (paramsJSON?: string | null) => {
agentId: string;
control: CodexSessionCatalogControl;
paramsJSON: string;
},
configSources: CodexTerminalConfigSources,
): OpenClawPluginNodeHostCommand {
return {
command: CODEX_TERMINAL_RESUME_COMMAND,
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ env }) =>
Boolean(
resolveNodeHostExecutable("codex", {
env,
pathEnv: env.PATH ?? env.Path ?? "",
strategy: "direct",
}),
),
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("Codex terminal command requires duplex transport");
}
const request = bindRequest(paramsJSON);
const resume = decodeNodePtyResumeParams(request.paramsJSON, (value) => {
if (
typeof value !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value)
) {
throw new CatalogParamsError("threadId must be a UUID");
}
return value;
});
const record = await requireCatalogEligibleThread(request.control, resume.threadId);
const resolution = resolveNodeHostExecutable("codex", {
env: process.env,
pathEnv: process.env.PATH ?? process.env.Path ?? "",
strategy: "direct",
});
if (!resolution) {
throw new Error("Codex CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file: resolution.executable,
args: ["resume", resume.threadId],
cwd: record.cwd,
env: {
CODEX_HOME: resolveCodexCatalogTerminalHome({
...configSources,
agentId: request.agentId,
}),
},
cols: resume.cols,
rows: resume.rows,
},
io,
),
);
},
};
}
async function resolveNodeCatalogEligibleThread(params: {
agentId: string;
runtime: PluginRuntime;
nodeId: string;
threadId: string;
parseCatalogPage: (value: unknown) => CodexSessionCatalogPage;
}): Promise<CodexSessionCatalogSession> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: CODEX_APP_SERVER_THREADS_LIST_COMMAND,
params: {
agentId: params.agentId,
limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
},
timeoutMs: NODE_INVOKE_TIMEOUT_MS,
scopes: ["operator.write"],
});
const page = params.parseCatalogPage(unwrapNodeInvokePayload(raw));
const record = page.sessions.find((candidate) => candidate.threadId === params.threadId);
if (record) {
if (isInteractiveThreadSource(record.source)) {
return record;
}
break;
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor || seenCursors.has(nextCursor)) {
break;
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new CatalogParamsError("Codex session is not a non-archived interactive Codex session");
}
export async function openCodexCatalogTerminal(
params: {
agentId: string;
api: OpenClawPluginApi;
control: CodexSessionCatalogControl;
hostId: string;
threadId: string;
parseCatalogPage: (value: unknown) => CodexSessionCatalogPage;
source?: CodexCatalogHome;
} & CodexTerminalConfigSources,
): Promise<SessionCatalogTerminalPlan> {
const title = `codex resume ${params.threadId.slice(0, 8)}…`;
if (
params.hostId === CODEX_LOCAL_SESSION_HOST_ID ||
params.hostId.startsWith(`${CODEX_LOCAL_SESSION_HOST_ID}:`)
) {
const record = await requireCatalogEligibleThread(params.control, params.threadId);
const resolution = resolveLocalCodexTerminalResolution();
// A managed app-server may exist without a local CLI. Fail closed so
// terminal resume never targets a different machine or missing binary.
if (!resolution) {
throw new CatalogParamsError("Codex CLI is unavailable");
}
return {
kind: "local",
argv: [resolution.executable, "resume", params.threadId],
...(record.cwd ? { cwd: record.cwd } : {}),
env: { CODEX_HOME: resolveCodexCatalogTerminalHome(params) },
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
title,
};
}
if (!params.hostId.startsWith("node:")) {
throw new CatalogParamsError("hostId is invalid");
}
const nodeId = params.hostId.slice("node:".length);
const node = (await params.api.runtime.nodes.list()).nodes.find((candidate) => {
const commands = candidate.invocableCommands ?? candidate.commands;
return (
candidate.nodeId === nodeId &&
candidate.connected === true &&
commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) === true &&
commands.includes(CODEX_TERMINAL_RESUME_COMMAND)
);
});
if (!node) {
throw new CatalogParamsError("paired-node Codex terminal is unavailable");
}
const record = await resolveNodeCatalogEligibleThread({
agentId: params.agentId,
runtime: params.api.runtime,
nodeId,
threadId: params.threadId,
parseCatalogPage: params.parseCatalogPage,
});
return {
kind: "node",
nodeId,
command: CODEX_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ agentId: params.agentId, threadId: params.threadId }),
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
export async function startCodexCatalogTerminal(
params: {
agentId: string;
cwd: string;
initialMessage?: string;
nodeId?: string;
} & CodexTerminalConfigSources,
): Promise<SessionCatalogTerminalPlan> {
if (params.nodeId) {
throw new CatalogParamsError(
"Paired-node Codex terminal start is unavailable; omit hostId to start on the gateway host",
);
}
const resolution = resolveLocalCodexTerminalResolution();
if (!resolution) {
throw new CatalogParamsError(
"Codex CLI is unavailable; install Codex or add codex to PATH, then try again",
);
}
return {
kind: "local",
argv: [
resolution.executable,
...(params.initialMessage !== undefined ? ["--", params.initialMessage] : []),
],
cwd: params.cwd,
env: { CODEX_HOME: resolveCodexCatalogTerminalHome(params) },
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
title: "codex",
};
}