Files
openclaw/extensions/codex/src/command-handlers.ts
T
Kimi Yu e1b8150a06 fix(codex): let owners install plugins from discovered marketplaces (#122389)
* fix(codex): allow owner-approved marketplace plugins

* fix(codex): preserve marketplace validation and remove unused exports

* docs(codex): clarify already-installed plugin authorization
2026-08-12 20:29:40 +00:00

381 lines
13 KiB
TypeScript

// Codex plugin module implements command handlers behavior.
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
import { defaultCodexAppInventoryCache } from "./app-server/app-inventory-cache.js";
import {
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerFallbackApiKeyCacheKey,
} from "./app-server/auth-bridge.js";
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
import { refreshCodexPluginRuntimeState } from "./app-server/plugin-activation.js";
import { buildCodexPluginAppCacheKey } from "./app-server/plugin-app-cache-key.js";
import { defaultCodexPluginMetadataCache } from "./app-server/plugin-metadata-cache.js";
import type { JsonValue, v2 } from "./app-server/protocol.js";
import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./app-server/shared-client.js";
import { readCodexAccountAuthOverview } from "./command-account.js";
import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js";
import { handleCodexDiagnosticsFeedback } from "./command-diagnostics.js";
import {
buildHelp,
formatAccount,
formatCodexDisplayText,
formatCodexStatus,
formatList,
formatModels,
formatSkills,
} from "./command-formatters.js";
import {
CODEX_NATIVE_CONTROL_SUBCOMMANDS,
handleComputerUseCommand,
handleNativeGoal,
isReadOnlyCodexGoalCommand,
resolveCodexNativeCommandSandboxBlock,
returnsBeforeNativeCodexExecution,
setConversationFastMode,
setConversationModel,
setConversationPermissions,
startThreadAction,
steerConversationTurn,
stopConversationTurn,
} from "./command-handler-actions.js";
import {
buildCodexComputerUseMenuReply,
buildCodexFastMenuReply,
buildCodexPermissionsMenuReply,
buildCodexSubcommandPickerReply,
isMenuVerb,
splitArgs,
} from "./command-handler-args.js";
import {
bindConversation,
buildCodexCliSessions,
buildThreads,
describeConversationBinding,
detachConversation,
resumeThread,
} from "./command-handler-bindings.js";
import {
CODEX_CONTROL_METHODS,
resolveCodexCommandDeps,
type CodexCommandDepsOverride,
} from "./command-handler-deps.js";
import {
resolveCodexConversationControlScope,
resolveCommandAppServerScope,
} from "./command-handler-scope.js";
import { handleCodexPluginsSubcommand } from "./command-plugins-management.js";
import { readCodexConversationBindingData } from "./conversation-binding-data.js";
export type { CodexCommandDepsOverride } from "./command-handler-deps.js";
export async function handleCodexSubcommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps: CodexCommandDepsOverride },
): Promise<PluginCommandResult> {
const deps = resolveCodexCommandDeps(options.deps);
const args = splitArgs(ctx.args);
if (args.length === 0) {
return buildCodexSubcommandPickerReply();
}
const [subcommand = "status", ...rest] = args;
const normalized = subcommand.toLowerCase();
if (normalized === "help") {
return { text: buildHelp() };
}
if (
CODEX_NATIVE_CONTROL_SUBCOMMANDS.has(normalized) &&
!returnsBeforeNativeCodexExecution(normalized, rest) &&
!isReadOnlyCodexGoalCommand(normalized, rest) &&
!canMutateCodexHost(ctx)
) {
return { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR };
}
const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest);
if (sandboxBlock) {
return { text: sandboxBlock };
}
if (normalized === "plugins") {
if (!deps.codexPluginsManagementIo) {
return {
text:
"Codex sub-plugin management is not wired up (codexPluginsManagementIo dep is undefined). " +
"Edit ~/.openclaw/openclaw.json or use `openclaw config patch` until the runtime exposes the IO.",
};
}
let appServerScope: ReturnType<typeof resolveCommandAppServerScope> | undefined;
const getAppServerScope = () =>
(appServerScope ??= resolveCommandAppServerScope(deps, ctx, options.pluginConfig));
return await handleCodexPluginsSubcommand(ctx, rest, deps.codexPluginsManagementIo, {
workspaceDir: async () => {
const data = readCodexConversationBindingData(await ctx.getCurrentConversationBinding());
const workspaceDir =
data?.kind === "codex-app-server-session" ? data.workspaceDir : undefined;
return workspaceDir?.trim() || deps.resolveCodexDefaultWorkspaceDir(options.pluginConfig);
},
list: async (requestParams) => {
const scope = await getAppServerScope();
return (await deps.codexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.listPlugins,
requestParams,
{ ...scope, config: ctx.config },
)) as v2.PluginListResponse;
},
install: async (requestParams) => {
const scope = await getAppServerScope();
return (await deps.codexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.installPlugin,
requestParams,
{ ...scope, config: ctx.config },
)) as v2.PluginInstallResponse;
},
refresh: async (workspaceDir) => {
const scope = await getAppServerScope();
const configuredAppServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: options.pluginConfig,
});
const appServer = scope.startOptions
? { ...configuredAppServer, start: scope.startOptions }
: configuredAppServer;
const authProfileId = scope.authProfileId ?? undefined;
const accountId = await resolveCodexAppServerAuthAccountCacheKey({
authProfileId,
agentDir: scope.agentDir,
config: ctx.config,
});
const client = await getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
pluginConfig: options.pluginConfig,
authProfileId: scope.authProfileId,
agentDir: scope.agentDir,
config: ctx.config,
});
try {
const appCacheKey = buildCodexPluginAppCacheKey({
appServer,
agentDir: scope.agentDir,
authProfileId,
accountId,
envApiKeyFingerprint: authProfileId
? undefined
: resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions: appServer.start }),
appServerVersion: client.getServerVersion(),
runtimeIdentity: client.getRuntimeIdentity(),
});
defaultCodexPluginMetadataCache.invalidate(appCacheKey);
return await refreshCodexPluginRuntimeState({
configCwd: workspaceDir,
appCache: defaultCodexAppInventoryCache,
appCacheKey,
metadataCache: defaultCodexPluginMetadataCache,
request: async (method, requestParams) => {
const requestMethod = resolvePluginRuntimeRefreshMethod(method);
return await deps.codexControlRequest(
options.pluginConfig,
requestMethod,
requestParams as JsonValue | undefined,
{ ...scope, config: ctx.config },
);
},
});
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
},
});
}
if (normalized === "status") {
if (rest.length > 0) {
return { text: "Usage: /codex status" };
}
const { agentDir } = resolveCodexConversationControlScope(ctx);
return {
text: formatCodexStatus(
await deps.readCodexStatusProbes(options.pluginConfig, ctx.config, agentDir),
),
};
}
if (normalized === "models") {
if (rest.length > 0) {
return { text: "Usage: /codex models" };
}
const { agentDir } = resolveCodexConversationControlScope(ctx);
return {
text: formatModels(
await deps.listCodexAppServerModels(
deps.requestOptions(options.pluginConfig, 100, ctx.config, agentDir),
),
),
};
}
if (normalized === "threads") {
return { text: await buildThreads(deps, ctx, options.pluginConfig, rest.join(" ")) };
}
if (normalized === "goal") {
return { text: await handleNativeGoal(deps, ctx, options.pluginConfig, rest) };
}
if (normalized === "sessions") {
return { text: await buildCodexCliSessions(deps, rest) };
}
if (normalized === "resume") {
return { text: await resumeThread(deps, ctx, options.pluginConfig, rest) };
}
if (normalized === "bind") {
return await bindConversation(deps, ctx, options.pluginConfig, rest);
}
if (normalized === "detach" || normalized === "unbind") {
if (rest.length > 0) {
return { text: "Usage: /codex detach" };
}
return { text: await detachConversation(deps, ctx) };
}
if (normalized === "binding") {
if (rest.length > 0) {
return { text: "Usage: /codex binding" };
}
return { text: await describeConversationBinding(deps, ctx) };
}
if (normalized === "stop") {
if (rest.length > 0) {
return { text: "Usage: /codex stop" };
}
return { text: await stopConversationTurn(deps, ctx, options.pluginConfig) };
}
if (normalized === "steer") {
return {
text: await steerConversationTurn(deps, ctx, options.pluginConfig, rest.join(" ")),
};
}
if (normalized === "model") {
return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest) };
}
if (normalized === "fast") {
if (isMenuVerb(rest)) {
return buildCodexFastMenuReply();
}
return { text: await setConversationFastMode(deps, ctx, rest) };
}
if (normalized === "permissions") {
if (isMenuVerb(rest)) {
return buildCodexPermissionsMenuReply();
}
return { text: await setConversationPermissions(deps, ctx, rest) };
}
if (normalized === "compact") {
return {
text: await startThreadAction(deps, ctx, options.pluginConfig, "compact", rest),
};
}
if (normalized === "review") {
return {
text: await startThreadAction(deps, ctx, options.pluginConfig, "review", rest),
};
}
if (normalized === "diagnostics") {
return await handleCodexDiagnosticsFeedback(
deps,
ctx,
options.pluginConfig,
rest.join(" "),
"/codex diagnostics",
);
}
if (normalized === "computer-use" || normalized === "computeruse") {
if (isMenuVerb(rest)) {
return buildCodexComputerUseMenuReply();
}
return {
text: await handleComputerUseCommand(deps, ctx, options.pluginConfig, rest),
};
}
if (normalized === "mcp") {
if (rest.length > 0) {
return { text: "Usage: /codex mcp" };
}
const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig);
return {
text: formatList(
await deps.codexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.listMcpServers,
{ limit: 100 },
{ config: ctx.config, ...scope },
),
"MCP servers",
),
};
}
if (normalized === "skills") {
if (rest.length > 0) {
return { text: "Usage: /codex skills" };
}
const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig);
return {
text: formatSkills(
await deps.codexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.listSkills,
{},
{ config: ctx.config, ...scope },
),
),
};
}
if (normalized === "account") {
if (rest.length > 0) {
return { text: "Usage: /codex account" };
}
const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig);
const requestScope = { config: ctx.config, ...scope };
const [account, limits] = await Promise.all([
deps.safeCodexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.account,
{ refreshToken: false },
requestScope,
),
deps.safeCodexControlRequest(
options.pluginConfig,
CODEX_CONTROL_METHODS.rateLimits,
undefined,
requestScope,
),
]);
return {
text: formatAccount(
account,
limits,
await readCodexAccountAuthOverview({
ctx,
agentDir: scope.agentDir,
pluginConfig: options.pluginConfig,
safeCodexControlRequest: deps.safeCodexControlRequest,
account,
limits,
}),
),
};
}
return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` };
}
function resolvePluginRuntimeRefreshMethod(method: string) {
const supported = [
CODEX_CONTROL_METHODS.listPlugins,
CODEX_CONTROL_METHODS.listSkills,
CODEX_CONTROL_METHODS.listHooks,
CODEX_CONTROL_METHODS.reloadMcpServers,
CODEX_CONTROL_METHODS.installedApps,
CODEX_CONTROL_METHODS.listApps,
CODEX_CONTROL_METHODS.readApps,
] as const;
const recognized = supported.find((candidate) => candidate === method);
if (!recognized) {
throw new Error(`Unexpected Codex plugin refresh method: ${method}`);
}
return recognized;
}