Files
openclaw/src/plugins/session-conversation-binding.ts
Peter Steinberger 877af700bf feat(codex): continue paired-node Codex catalog sessions from the Control UI (#106927)
* feat(codex): continue paired-node Codex catalog sessions from the Control UI

Codex catalog rows on paired nodes were view-only. Node rows are now
continuable when the node advertises and permits the codex threads.list,
thread.turns.list, and cli.session.resume commands. Continue adopts the
session into a locked codex OpenClaw session (deterministic
harness:codex:node-session key, bounded native-history import, two-phase
initializing marker) and installs a webchat conversation binding
(codex-cli-node-session) through the new generic
bindPluginSessionConversation seam with attempt-ID guarded rollback, so
each web turn runs codex exec resume on the owning node via the existing
inbound_claim path. Local continue/archive semantics are unchanged;
archived rows are no longer offered as continuable.

* fix(plugins): serialize plugin session binds per session

Review P2: the bind rollback checked the attempt id and then awaited the
unbind, so a concurrent Continue could install a newer binding between the
two and lose it to the older attempt's rollback. All session binds for one
session key now queue through an in-process tail, so a failing attempt can
only ever roll back its own binding; the attempt-id guard stays as defense
against out-of-band rebinds.

* refactor(codex): extract node-continue and session-bind modules for the LOC ratchet

Pure movement to satisfy the changed-file TypeScript size ratchet: the
paired-node continue flow moves to session-catalog-node-continue.ts and
session-catalog-node-adoption.ts, catalog paging/parsing helpers to
session-catalog-parsing.ts, the Control UI session-bind seam to
session-conversation-binding.ts (+ session-key helpers), and the internal
conversation-id/channel-prefix helpers to utils. transcript-mirror.ts is
restored to main byte-for-byte; the node history builder constructs a plain
CodexThread (only id is required) instead of widening signatures.

* fix(plugins): satisfy preserve-caught-error in the session-bind rollback

* fix(codex): gate node continue on operator.admin and resume by CLI session id

Second review round: (1) bound node turns pass canMutateCodexHost only for
owners/admins, while sessions.catalog.continue is operator.write — a
write-scoped Control UI client could adopt a node session whose every turn
would then be rejected. The gateway now forwards the caller's scopes to the
provider (SessionCatalogContinueProviderParams.clientScopes) and codex fails
node continues closed without operator.admin, before the dedupe join so a
non-admin cannot ride an admin's in-flight operation. (2) The node binding
stored the app-server threadId, but codex exec resume takes the CLI session
id and forked threads share a session tree where the two differ; the binding
now prefers record.sessionId.

* fix(codex): defer node-session restore until the binding is durable

Third review round: continuing an archived adopted node session unarchived
it before the gateway installed the conversation binding, so a failed bind
left a visible session with no node routing. The unarchive now rides
afterConversationBound; finalizeNodeAdoptedSession already covers both the
initializing and finalized marker states, so the eager restore helper is
deleted.
2026-07-13 19:54:31 -07:00

115 lines
4.0 KiB
TypeScript

import crypto from "node:crypto";
import {
createConversationBindingRecord,
resolveConversationBindingRecord,
unbindConversationBindingRecord,
} from "../bindings/records.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
import { bindConversationNow, buildPluginBindingIdentity } from "./conversation-binding.js";
import type {
PluginConversationBinding,
PluginConversationBindingRequestParams,
} from "./conversation-binding.types.js";
const log = createSubsystemLogger("plugins/binding");
// Serializes bind+finalize+rollback per session so a failing older attempt
// can never unbind or restore over a newer successful one (all session binds
// go through this in-process seam).
const pluginSessionBindTails = new Map<string, Promise<void>>();
/** Binds a plugin-owned runtime to one authenticated Control UI session. */
export async function bindPluginSessionConversation(params: {
pluginId: string;
pluginName?: string;
pluginRoot: string;
sessionKey: string;
binding: PluginConversationBindingRequestParams;
afterBind?: () => Promise<void>;
}): Promise<PluginConversationBinding> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("session key is required for a plugin session binding");
}
const previousTail = pluginSessionBindTails.get(sessionKey) ?? Promise.resolve();
const operation = previousTail.then(() =>
bindPluginSessionConversationExclusive({ ...params, sessionKey }),
);
const tail = operation.then(
() => undefined,
() => undefined,
);
pluginSessionBindTails.set(sessionKey, tail);
try {
return await operation;
} finally {
if (pluginSessionBindTails.get(sessionKey) === tail) {
pluginSessionBindTails.delete(sessionKey);
}
}
}
async function bindPluginSessionConversationExclusive(params: {
pluginId: string;
pluginName?: string;
pluginRoot: string;
sessionKey: string;
binding: PluginConversationBindingRequestParams;
afterBind?: () => Promise<void>;
}): Promise<PluginConversationBinding> {
const sessionKey = params.sessionKey;
const conversation = {
channel: INTERNAL_MESSAGE_CHANNEL,
accountId: "default",
conversationId: sessionKey,
};
const previous = resolveConversationBindingRecord(conversation);
const bindingAttemptId = crypto.randomUUID();
const binding = await bindConversationNow({
identity: buildPluginBindingIdentity(params),
conversation,
targetSessionKey: sessionKey,
summary: params.binding.summary,
detachHint: params.binding.detachHint,
data: params.binding.data,
bindingAttemptId,
});
try {
await params.afterBind?.();
return binding;
} catch (error) {
const current = resolveConversationBindingRecord(conversation);
if (current?.metadata?.bindingAttemptId !== bindingAttemptId) {
throw error;
}
try {
await unbindConversationBindingRecord({
bindingId: current.bindingId,
reason: "plugin-session-bind-rollback",
});
if (previous && (previous.expiresAt === undefined || previous.expiresAt > Date.now())) {
await createConversationBindingRecord({
targetSessionKey: previous.targetSessionKey,
targetKind: previous.targetKind,
conversation: previous.conversation,
placement: "current",
metadata: previous.metadata,
...(previous.expiresAt === undefined
? {}
: { ttlMs: Math.max(1, previous.expiresAt - Date.now()) }),
});
}
} catch (rollbackError) {
// The finalize failure is superseded by the rollback failure on the
// throw path; keep it observable for diagnosis.
log.warn("plugin session binding finalization failed before rollback", { error });
throw new Error(
"plugin session binding finalization failed and its previous binding could not be restored",
{ cause: rollbackError },
);
}
throw error;
}
}