Files
openclaw/extensions/codex/index.ts
Peter Steinberger 0924fd9a0c fix(codex): reclaim binding capacity after session deletion (#128366)
* fix(codex): reclaim binding ownership when sessions are deleted

Prepare harness deletion outside the SQLite writer and compare-delete exact companion ownership at the session removal edge, with compensation before commit and native subscription release afterward. Preserve live siblings and upstream native history. Repair proven orphan bindings through the existing Doctor planner under maintenance ownership.

* fix(codex): reuse prepared auth for resumed thread ownership

* docs(plugins): clarify shared migration and auth contracts

* test(codex): use the canonical config type facade

* fix(codex): preserve native threads during manual resume

Record pending attachment intent and validate the immutable native tool catalog
before applying configuration to the selected thread. Require uninterrupted
physical-client ownership through native unsubscribe, resume, and binding commit.
Keep the thread intact on unsupported policy, competing work, or failed proof.

Reuse canonical retained-owner cleanup and preserve unsafe-client retirement.
Cover the real command producer, cold and loaded resumes, competing leases,
lazy-store commit fencing, and the Gateway competing-owner path.

* test: isolate Codex auth and session completion fixtures
2026-08-26 20:33:53 -07:00

418 lines
18 KiB
TypeScript

/**
* Bundled Codex plugin entry: app-server harness, media understanding,
* migration provider, CLI-session commands, and binding hooks.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveLivePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { registerCodexCliMetadata } from "./cli-metadata.js";
import {
createCodexAppServerAgentHarness,
createCodexAppServerNativeCompaction,
} from "./harness.js";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { readCodexPluginConfig } from "./src/app-server/config.js";
import { createCodexAppServerConnectionHealthService } from "./src/app-server/connection-health.js";
import { createCodexDesktopGenerationService } from "./src/app-server/desktop-generation.js";
import { setManagedCodexPluginRoot } from "./src/app-server/managed-binary.js";
import {
CODEX_MANAGED_THREAD_MAX_ENTRIES,
CODEX_MANAGED_THREAD_NAMESPACE,
type StoredCodexManagedThread,
} from "./src/app-server/managed-thread-store.js";
import {
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
createLazyCodexAppServerBindingStore,
type StoredCodexAppServerBinding,
} from "./src/app-server/session-binding-store.js";
import { retireSharedCodexAppServerClientsBeforeDesktopGeneration } from "./src/app-server/shared-client.js";
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
import { createCodexCommand } from "./src/commands.js";
import { codexConversationBindingRuntime } from "./src/conversation-binding.js";
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
import { createCodexPluginsTool } from "./src/native-plugin-tool.js";
import { createCodexThreadsTool } from "./src/native-thread-tool.js";
import {
createCodexCliSessionNodeHostCommands,
createCodexCliSessionNodeInvokePolicies,
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
import {
createCodexNodeExecServerCommand,
createCodexNodeExecServerInvokePolicy,
} from "./src/node-exec-server.js";
import {
createCodexSessionCatalogControl,
createCodexSessionCatalogNodeHostCommands,
createCodexSessionCatalogNodeInvokePolicies,
codexSessionCatalogRuntime,
} from "./src/session-catalog.js";
import {
CODEX_SUPERVISION_COMPAT_TOOL_NAMES,
createCodexSupervisionTools,
} from "./src/supervision-tools.js";
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
const ENDED_SESSION_REASONS: ReadonlySet<string> = new Set(["new", "reset", "idle", "daily"]);
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and native session supervision.",
register(api) {
// Bundled modules may execute from a shared dist chunk, so import.meta.url
// cannot identify the owning plugin package or its pinned dependencies.
setManagedCodexPluginRoot(api.rootDir);
const resolveCurrentConfig = () =>
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
const resolvePluginConfig = (resolveConfig: () => OpenClawConfig | undefined) => {
const liveConfig = resolveConfig();
// Codex plugin config can change at runtime. A missing live entry is an
// explicit removal, while an unavailable runtime snapshot uses startup config.
if (!liveConfig) {
return api.pluginConfig;
}
const livePluginConfig = resolveLivePluginConfigObject(
() => liveConfig,
"codex",
api.pluginConfig as Record<string, unknown>,
);
const enabled = resolveEffectiveEnableState({
id: "codex",
origin: "bundled",
config: normalizePluginsConfig(liveConfig.plugins),
rootConfig: liveConfig,
// Core auto-enables this bundled plugin whenever the operator declares a
// codex config block, so a live block is the plugin-side default. Gating
// on a feature flag (supervision) here would silently drop unrelated
// harness settings such as appServer.homeScope; feature gates belong in
// the feature's own surface (see requireSupervisionEnabled).
enabledByDefault: livePluginConfig !== undefined,
}).enabled;
if (!enabled) {
return undefined;
}
return livePluginConfig;
};
const resolveCurrentPluginConfig = () => resolvePluginConfig(resolveCurrentConfig);
const appServerConfig = readCodexPluginConfig(resolveCurrentPluginConfig()).appServer;
api.registerService(
createCodexDesktopGenerationService({
onGenerationChange: retireSharedCodexAppServerClientsBeforeDesktopGeneration,
}),
);
if (appServerConfig?.transport === "websocket") {
api.registerService(
createCodexAppServerConnectionHealthService({
getPluginConfig: resolveCurrentPluginConfig,
getRuntimeConfig: resolveCurrentConfig,
}),
);
}
let bindingStateStore: PluginStateSyncKeyedStore<StoredCodexAppServerBinding> | undefined;
let managedThreadStateStore: PluginStateSyncKeyedStore<StoredCodexManagedThread> | undefined;
const openBindingStateStore = () =>
(bindingStateStore ??= api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
}));
// The base registration runtime deliberately rejects state access. Open the
// store only when a proxied runtime performs the first binding operation.
const lazyBindingStateStore: Pick<
PluginStateSyncKeyedStore<StoredCodexAppServerBinding>,
"deleteIf" | "entries" | "lookup" | "registerIfAbsent" | "update"
> = {
deleteIf: (key, predicate) => openBindingStateStore().deleteIf!(key, predicate),
entries: () => openBindingStateStore().entries(),
lookup: (key) => openBindingStateStore().lookup(key),
registerIfAbsent: (key, value, options) =>
openBindingStateStore().registerIfAbsent(key, value, options),
get update() {
const store = openBindingStateStore();
return store.update?.bind(store);
},
};
const openManagedThreadStateStore = () =>
(managedThreadStateStore ??= api.runtime.state.openSyncKeyedStore<StoredCodexManagedThread>({
namespace: CODEX_MANAGED_THREAD_NAMESPACE,
maxEntries: CODEX_MANAGED_THREAD_MAX_ENTRIES,
// Catalog-only ownership may evict its oldest row. Modern rollouts/transcripts are
// rediscovered from provenance; very old markerless sessions may reappear after eviction.
overflowPolicy: "evict-oldest",
}));
const lazyManagedThreadStateStore: Pick<
PluginStateSyncKeyedStore<StoredCodexManagedThread>,
"entries" | "registerIfAbsent"
> = {
entries: () => openManagedThreadStateStore().entries(),
registerIfAbsent: (key, value) => openManagedThreadStateStore().registerIfAbsent(key, value),
};
const bindingStore = createLazyCodexAppServerBindingStore(
lazyBindingStateStore,
lazyManagedThreadStateStore,
);
registerCodexCliMetadata(api);
const sessionCatalogControlFactory = createCodexSessionCatalogControl({
config: api.config as OpenClawConfig,
getPluginConfig: resolveCurrentPluginConfig,
getRuntimeConfig: resolveCurrentConfig,
});
const sessionCatalogEnabled =
readCodexPluginConfig(resolveCurrentPluginConfig()).sessionCatalog?.enabled !== false;
if (sessionCatalogEnabled) {
codexSessionCatalogRuntime.register({
api,
bindingStore,
control: sessionCatalogControlFactory,
getPluginConfig: resolveCurrentPluginConfig,
getRuntimeConfig: resolveCurrentConfig,
});
for (const command of createCodexSessionCatalogNodeHostCommands(
sessionCatalogControlFactory,
{
getPluginConfig: resolveCurrentPluginConfig,
getRuntimeConfig: () => resolveCurrentConfig() ?? (api.config as OpenClawConfig),
},
bindingStore,
)) {
api.registerNodeHostCommand(command);
}
}
for (const policy of createCodexSessionCatalogNodeInvokePolicies()) {
api.registerNodeInvokePolicy(policy);
}
if (readCodexPluginConfig(resolveCurrentPluginConfig()).supervision?.enabled === true) {
api.registerTool(
(context) => {
if (context.senderIsOwner !== true) {
return [];
}
const resolveToolRuntimeConfig = () =>
context.getRuntimeConfig?.() ??
context.runtimeConfig ??
context.config ??
resolveCurrentConfig();
return createCodexSupervisionTools({
getPluginConfig: () => resolvePluginConfig(resolveToolRuntimeConfig),
getRuntimeConfig: resolveToolRuntimeConfig,
senderIsOwner: context.senderIsOwner,
});
},
{ names: [...CODEX_SUPERVISION_COMPAT_TOOL_NAMES] },
);
}
const agentHarnessOptions = {
bindingStore,
sessionCatalogControlFactory,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
runtime: api.runtime,
};
api.registerAgentHarness(createCodexAppServerAgentHarness(agentHarnessOptions), {
nativeCompaction: createCodexAppServerNativeCompaction(agentHarnessOptions),
});
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerWebSearchProvider(
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
api.registerTool(
(context) =>
createCodexThreadsTool({
bindingStore,
context,
runtime: api.runtime,
getPluginConfig: resolveCurrentPluginConfig,
}),
{ name: "codex_threads" },
);
api.registerToolMetadata({
toolName: "codex_threads",
displayName: "Codex Threads",
description: "Manage native Codex threads in the shared user Codex home.",
risk: "high",
tags: ["codex", "sessions"],
});
api.registerTool(
(context) =>
createCodexPluginsTool({
bindingStore,
context,
getPluginConfig: resolveCurrentPluginConfig,
}),
{ name: "codex_plugins" },
);
api.registerToolMetadata({
toolName: "codex_plugins",
displayName: "Codex Plugins",
description: "Discover available Codex plugins without installing or enabling them.",
risk: "low",
tags: ["codex", "plugins", "discovery"],
});
for (const command of createCodexCliSessionNodeHostCommands()) {
api.registerNodeHostCommand(command);
}
for (const policy of createCodexCliSessionNodeInvokePolicies()) {
api.registerNodeInvokePolicy(policy);
}
api.registerNodeHostCommand(createCodexNodeExecServerCommand());
api.registerNodeInvokePolicy(createCodexNodeExecServerInvokePolicy());
api.registerCommand(
createCodexCommand({
pluginConfig: api.pluginConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
deps: {
bindingStore,
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
codexPluginsManagementIo: {
readConfig: () => {
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
const plugins = (current as Record<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
mutate: async (update) => {
await mutateConfigFile({
mutate: (draft) => {
// Create the nested plugin config path on demand so codex
// plugin commands can enable/update Codex-managed plugins.
const root = draft as Record<string, unknown>;
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
},
},
},
}),
);
api.on("inbound_claim", (event, ctx) =>
codexConversationBindingRuntime.handleInboundClaim(event, ctx, {
bindingStore,
pluginConfig: resolveCurrentPluginConfig(),
config: resolveCurrentConfig(),
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.((event) =>
codexConversationBindingRuntime.handleBindingResolved(event, { bindingStore }),
);
api.on("after_compaction", async (event, ctx) => {
const previousSessionId = event.previousSessionId?.trim();
const sessionId = ctx.sessionId?.trim();
if (!previousSessionId || !sessionId || previousSessionId === sessionId) {
return;
}
const config = resolveCurrentConfig();
const sessionKey = ctx.sessionKey?.trim();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
});
const adopted = await bindingStore.adoptSessionGeneration(identity, previousSessionId);
if (adopted === "conflict") {
api.logger.warn?.(
`codex: could not adopt compacted session generation ${sessionId} (${adopted}); secondary native compaction will skip`,
);
}
});
api.on("session_end", async (event, ctx) => {
if (!event.reason || !ENDED_SESSION_REASONS.has(event.reason)) {
return;
}
const sessionKey = event.sessionKey ?? ctx.sessionKey;
// A cross-key handoff (dashboard "New Chat", a fork) fires session_end on
// the parent only to start an INDEPENDENT child session under a different
// key; that child owns its own Codex thread binding (a Codex fork is a new
// thread, not a transfer of the parent's). Retiring the parent's still-live
// binding here would strand it, so skip when the successor provably lives
// under a different session key. The only cross-key emitter (gateway child
// creation) keeps the parent row live; same-key rollovers omit or repeat
// the key and still retire, as do unknown-current-key ends (no provable
// handoff) and later idle/daily ends. See #106778.
const endedSessionKey = sessionKey?.trim();
const nextSessionKey = event.nextSessionKey?.trim();
if (endedSessionKey && nextSessionKey && nextSessionKey !== endedSessionKey) {
return;
}
// Reset hooks already clear in-place lifecycle state before the next turn.
// A delayed session_end must not retire a replacement that reuses the id.
if (event.nextSessionId?.trim() === event.sessionId.trim()) {
return;
}
const config = resolveCurrentConfig();
const [{ sessionBindingIdentity }, { retireCodexAppServerSessionGeneration }] =
await Promise.all([
import("./src/app-server/session-binding.js"),
import("./src/app-server/session-retirement.js"),
]);
await retireCodexAppServerSessionGeneration({
bindingStore,
identity: sessionBindingIdentity({
sessionId: event.sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
}),
mode: "retire",
});
});
},
});