From 644d4c3d2388fd2e60705e1d48b353db619dc3af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 00:18:18 -0700 Subject: [PATCH] fix: explain and control native session discovery (#107162) * fix: make native session discovery configurable * refactor: split native session discovery helpers * chore: keep release notes in PR body * fix: refresh generated session protocol models * test(ui): avoid shared lazy-element mock race --- docs/nodes/index.md | 10 +- docs/plugins/codex-harness-reference.md | 15 +- docs/plugins/codex-harness.md | 12 +- docs/plugins/codex-supervision.md | 12 +- docs/specs/codex-supervision.md | 14 +- extensions/anthropic/index.test.ts | 26 +++ extensions/anthropic/openclaw.plugin.json | 20 +- extensions/anthropic/register.runtime.ts | 12 +- .../anthropic/session-catalog-node-helpers.ts | 17 ++ .../anthropic/session-catalog-registration.ts | 25 +++ extensions/anthropic/session-catalog.test.ts | 23 +++ extensions/anthropic/session-catalog.ts | 15 +- extensions/codex/index.test.ts | 36 ++++ extensions/codex/index.ts | 20 +- extensions/codex/openclaw.plugin.json | 20 +- .../codex/src/app-server/config.test.ts | 6 + extensions/codex/src/app-server/config.ts | 19 +- .../app-server/session-discovery-config.ts | 12 ++ .../codex/src/session-catalog-parsing.ts | 22 +- extensions/codex/src/session-catalog.test.ts | 10 + .../app-sidebar-session-catalogs.ts | 11 +- ui/src/components/app-sidebar.test.ts | 7 +- ui/src/e2e/codex-sessions.e2e.test.ts | 188 ++++++++++++++++++ .../chat/components/chat-tool-cards.test.ts | 23 +-- 24 files changed, 489 insertions(+), 86 deletions(-) create mode 100644 extensions/anthropic/session-catalog-node-helpers.ts create mode 100644 extensions/anthropic/session-catalog-registration.ts create mode 100644 extensions/codex/src/app-server/session-discovery-config.ts diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 2328c4ad4f71..d00422fb52a4 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -289,6 +289,9 @@ A desktop or server node can expose chat-capable models from an Ollama server ru The official `codex` plugin can expose non-archived Codex sessions on a headless node host or native macOS node. Catalog registration no longer depends on `supervision.enabled`; that option gates the agent-facing supervision tools. +Set `sessionCatalog.enabled: false` in the Codex plugin config to disable the +operator catalog and paired-node catalog commands without disabling the +provider or harness. The plugin must still be active on both computers, and the node setting remains local consent: enabling only the Gateway cannot read another computer's Codex state. @@ -316,8 +319,11 @@ pagination, local continuation, and the metadata security boundary. ### Claude sessions and transcripts The bundled `anthropic` plugin discovers non-archived Claude CLI and Claude -Desktop sessions on the Gateway and paired nodes. Unlike Codex supervision, -this needs no separate opt-in: a remote macOS app node advertises +Desktop sessions on the Gateway and paired nodes by default. Set +`plugins.entries.anthropic.config.sessionCatalog.enabled: false` to disable the +operator catalog and paired-node catalog commands without disabling Anthropic +models or the Claude CLI backend. +A remote macOS app node advertises `anthropic.claude.sessions.list.v1` and `anthropic.claude.sessions.read.v1` when the Anthropic plugin is enabled and `~/.claude/projects/` exists. Approve the node pairing upgrade when those commands first appear. diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index d0839e5eb966..d8be8aad8c9c 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -46,12 +46,13 @@ Top-level fields: | `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | | `codexPlugins` | disabled | Native Codex plugin/app support, including opt-in access to connected account apps. See [Native Codex plugins](/plugins/codex-native-plugins). | | `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | -| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. See [Codex supervision](/plugins/codex-supervision). | +| `sessionCatalog` | enabled | Native Codex session discovery for the sidebar. Set `enabled: false` to disable discovery without disabling the provider or harness. | +| `supervision` | disabled | Agent-facing native-session transcript and write-control policy. See [Codex supervision](/plugins/codex-supervision). | ## Supervision -Supervision lists non-archived Codex sessions from the Gateway computer and -opted-in paired nodes. Enable it independently from the agent harness: +Native session discovery lists non-archived Codex sessions from the Gateway +computer and opted-in paired nodes by default. Disable only that catalog with: ```json5 { @@ -60,8 +61,8 @@ opted-in paired nodes. Enable it independently from the agent harness: codex: { enabled: true, config: { - supervision: { - enabled: true, + sessionCatalog: { + enabled: false, }, }, }, @@ -70,11 +71,11 @@ opted-in paired nodes. Enable it independently from the agent harness: } ``` -`supervision` fields: +`supervision` separately controls agent-facing tools: | Field | Default | Meaning | | --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `false` | Advertise the local session catalog and, on the Gateway, aggregate opted-in paired-node catalogs for the Codex Sessions page. | +| `enabled` | `false` | Enable agent-facing Codex supervision tools. This does not control the authenticated operator session catalog. | | `endpoints` | built-in local endpoint | Compatibility and advanced endpoint targets for the retained Codex supervision agent and standalone MCP tools. The human catalog and branch flow ignore these targets and use the supervision App Server resolved from `appServer`. | | `allowRawTranscripts` | `false` | With supervision enabled, allow autonomous agent or standalone MCP transcript reads and transcript-derived list fields. `codex_threads` metadata-only reads remain available. Does not control authenticated Control UI continuation. | | `allowWriteControls` | `false` | With supervision enabled, allow autonomous `codex_threads` fork, rename, archive, and unarchive mutations plus standalone MCP send, steer, and interrupt operations. Does not bypass other binding, host, status, or confirmation checks. | diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 1227bd09da68..9efce9727cb7 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -153,9 +153,10 @@ stdio App Servers. Codex coordinates live writers inside one App Server, not across separate processes. Forking is the safe coexistence path for ordinary user-home stdio sessions. -`appServer.homeScope: "user"` alone does not enable the fleet catalog. Use -`supervision.enabled: true` when you want native sessions to appear in the -OpenClaw sidebar. Supervision uses a separate supervision connection; without +`appServer.homeScope: "user"` alone does not control the fleet catalog. Native +session discovery is enabled while the plugin is active; set +`sessionCatalog.enabled: false` to remove it from the OpenClaw sidebar without +disabling Codex. The catalog uses a separate supervision connection; without explicit `appServer` connection settings, that connection defaults to managed user-home stdio while the ordinary harness stays agent-scoped. Explicit `appServer` settings are honored by both paths. Set `homeScope: "user"` @@ -183,7 +184,7 @@ rules, paired-node limits, metadata exposure, and troubleshooting. | Need | Set | Where | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------- | | Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | -| Show non-archived Codex sessions | `plugins.entries.codex.config.supervision.enabled: true` | Codex plugin config | +| Hide native Codex session discovery | `plugins.entries.codex.config.sessionCatalog.enabled: false` | Codex plugin config | | Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | | Allow eligible OpenAI turns to use Codex implicitly | Exact official HTTPS Responses/ChatGPT route, no authored request override, runtime unset/`auto` | OpenAI provider/model config | | Sign in with ChatGPT/Codex OAuth | `openclaw models auth login --provider openai` | CLI auth profile | @@ -673,7 +674,8 @@ Supported top-level Codex plugin fields: | `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | | `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | | `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. | -| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. | +| `sessionCatalog` | enabled | Sidebar discovery for native Codex sessions on this Gateway and eligible paired nodes. | +| `supervision` | disabled | Agent-facing native-session transcript and write-control policy. | Supported `appServer` fields: diff --git a/docs/plugins/codex-supervision.md b/docs/plugins/codex-supervision.md index d5e4ccef76c4..9c2554b08b63 100644 --- a/docs/plugins/codex-supervision.md +++ b/docs/plugins/codex-supervision.md @@ -58,7 +58,10 @@ plugin activation succeeds. App Server availability is checked when supervision first connects. An explicit Codex plugin disable or policy block prevents opportunistic activation, and an existing explicit `supervision.enabled: false` disables agent-facing supervision tools; the -operator catalog remains registered whenever the Codex plugin is active. +operator catalog remains registered whenever the Codex plugin is active unless +`sessionCatalog.enabled: false` disables it. This separate switch leaves the +Codex provider, harness, and agent-facing supervision policy unchanged while +also removing the paired-node catalog list/read commands from this host. Existing installations can enable the same capability manually: Enable the `codex` plugin and its supervision capability in `openclaw.json`: @@ -142,6 +145,13 @@ does not change a thread's native status to `offline`. Session rows use Codex statuses such as `idle`, `active`, `notLoaded`, or error. A failed host does not hide results from healthy hosts. +The sidebar warning includes the catalog error code and the safe underlying +Gateway error. Open **Settings > Automation > Plugins > Codex > Native Session +Discovery** to disable discovery without disabling Codex. For +`NODE_LIST_FAILED`, compare `openclaw nodes list` and **Settings > Devices**; +the detailed cause identifies the pairing-store, node-registry, permission, or +Gateway lifecycle failure that needs repair. + ## Use the operator CLI The terminal CLI exposes the same non-archived catalog and Gateway-local branch diff --git a/docs/specs/codex-supervision.md b/docs/specs/codex-supervision.md index 948490900a5f..ee6426b1acc2 100644 --- a/docs/specs/codex-supervision.md +++ b/docs/specs/codex-supervision.md @@ -21,8 +21,14 @@ Supervisor plugin or second Codex protocol implementation. ## Product boundary -The catalog registers whenever the Codex plugin is active. Enable agent-facing -supervision tools with: +The catalog registers whenever the Codex plugin is active unless native session +discovery is explicitly disabled with: + +```text +plugins.entries.codex.config.sessionCatalog.enabled = false +``` + +Enable agent-facing supervision tools with: ```text plugins.entries.codex.config.supervision.enabled = true @@ -54,7 +60,9 @@ backend passes its live check, independently of which primary backend the user selects. Supervision activates only when that opportunistic plugin setup succeeds. An explicit disabled plugin, policy block, or `supervision.enabled: false` remains authoritative for supervision tools, but -does not disable the operator session catalog. +does not disable the operator session catalog. `sessionCatalog.enabled: false` +disables operator discovery and paired-node catalog commands; the Codex +provider and harness remain active. ## Ownership diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 03a6d07b5c52..ea000fe8f4f7 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -3,6 +3,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { capturePluginRegistration, registerSingleProviderPlugin, @@ -92,6 +93,31 @@ describe("anthropic provider replay hooks", () => { }); }); + it("lets native session discovery be disabled without disabling Anthropic", () => { + const registerCliBackend = vi.fn(); + const registerNodeHostCommand = vi.fn(); + const registerProvider = vi.fn(); + const registerSessionCatalog = vi.fn(); + anthropicPlugin.register( + createTestPluginApi({ + id: "anthropic", + name: "Anthropic", + source: "test", + config: {}, + pluginConfig: { sessionCatalog: { enabled: false } }, + registerCliBackend, + registerNodeHostCommand, + registerProvider, + registerSessionCatalog, + }), + ); + + expect(registerCliBackend).toHaveBeenCalledOnce(); + expect(registerProvider).toHaveBeenCalledOnce(); + expect(registerNodeHostCommand).not.toHaveBeenCalled(); + expect(registerSessionCatalog).not.toHaveBeenCalled(); + }); + it("publishes Claude Sonnet 5 CLI metadata without downgrading its API contract", () => { expect( buildClaudeCliCatalogEntries().find((model) => model.id === "claude-sonnet-5"), diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 0bd14480ee70..18ace659f48b 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -317,6 +317,24 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": {} + "properties": { + "sessionCatalog": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + } + } + }, + "uiHints": { + "sessionCatalog": { + "label": "Native Session Discovery", + "help": "Controls whether Claude Code sessions from this Gateway and paired nodes appear in the sidebar. Disabling also removes this host's paired-node catalog commands without disabling Anthropic models or the Claude CLI backend. Node-list failures usually indicate a Gateway pairing-store or node-registry problem; verify Settings > Devices or run openclaw nodes list. Changes require a Gateway restart." + }, + "sessionCatalog.enabled": { + "label": "Discover Claude Code Sessions", + "help": "List native Claude Code sessions in the sidebar from this Gateway and eligible paired nodes." + } } } diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index f6750126df40..10824f328f71 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -54,11 +54,8 @@ import { normalizeAnthropicProviderConfigForProvider, } from "./config-defaults.js"; import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; -import { - createClaudeSessionNodeHostCommands, - createClaudeSessionNodeInvokePolicies, -} from "./session-catalog-node-commands.js"; -import { registerClaudeSessionCatalog } from "./session-catalog.js"; +import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js"; +import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js"; import { wrapAnthropicProviderStream } from "./stream-wrappers.js"; import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js"; @@ -922,10 +919,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { api.registerCliBackend(buildAnthropicCliBackend()); api.registerProvider(buildAnthropicProvider()); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); - registerClaudeSessionCatalog(api); - for (const command of createClaudeSessionNodeHostCommands()) { - api.registerNodeHostCommand(command); - } + registerClaudeSessionDiscovery(api); for (const policy of createClaudeSessionNodeInvokePolicies()) { api.registerNodeInvokePolicy(policy); } diff --git a/extensions/anthropic/session-catalog-node-helpers.ts b/extensions/anthropic/session-catalog-node-helpers.ts new file mode 100644 index 000000000000..984e3646404b --- /dev/null +++ b/extensions/anthropic/session-catalog-node-helpers.ts @@ -0,0 +1,17 @@ +export function createNodeListFailedError(error: unknown): { code: string; message: string } { + const detail = + error instanceof Error ? error.message.trim() : typeof error === "string" ? error.trim() : ""; + const summary = "Paired nodes could not be listed"; + return { + code: "NODE_LIST_FAILED", + message: detail && detail !== summary ? `${summary}: ${detail}` : summary, + }; +} + +export function resolveNodeLabel(node: { + displayName?: string; + remoteIp?: string; + nodeId: string; +}): string { + return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; +} diff --git a/extensions/anthropic/session-catalog-registration.ts b/extensions/anthropic/session-catalog-registration.ts new file mode 100644 index 000000000000..bb7dd330b831 --- /dev/null +++ b/extensions/anthropic/session-catalog-registration.ts @@ -0,0 +1,25 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js"; +import { registerClaudeSessionCatalog } from "./session-catalog.js"; + +function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean { + if (!pluginConfig || typeof pluginConfig !== "object") { + return true; + } + const sessionCatalog = (pluginConfig as { sessionCatalog?: unknown }).sessionCatalog; + return !( + sessionCatalog && + typeof sessionCatalog === "object" && + (sessionCatalog as { enabled?: unknown }).enabled === false + ); +} + +export function registerClaudeSessionDiscovery(api: OpenClawPluginApi): void { + if (!isClaudeSessionCatalogEnabled(api.pluginConfig)) { + return; + } + registerClaudeSessionCatalog(api); + for (const command of createClaudeSessionNodeHostCommands()) { + api.registerNodeHostCommand(command); + } +} diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 730a65cd6cfe..0332d69ef765 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -1079,6 +1079,29 @@ describe("Claude session catalog", () => { ]); }); + it("keeps the underlying paired-node list failure", async () => { + const runtime = { + nodes: { + list: vi.fn().mockRejectedValue(new Error("paired store is unreadable")), + }, + } as unknown as PluginRuntime; + + const result = await listClaudeSessionCatalog({ + runtime, + query: { hostIds: ["node:registry"] }, + }); + + expect(result.hosts).toEqual([ + expect.objectContaining({ + hostId: "node:registry", + error: { + code: "NODE_LIST_FAILED", + message: "Paired nodes could not be listed: paired store is unreadable", + }, + }), + ]); + }); + it("rejects malformed fields returned by a paired node", async () => { const runtime = { nodes: { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 5e67fabbfa81..55fb0e51ae44 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -19,6 +19,7 @@ import { adoptedSourceKey, CLAUDE_LOCAL_SESSION_HOST_ID, } from "./session-catalog-adoption.js"; +import { createNodeListFailedError, resolveNodeLabel } from "./session-catalog-node-helpers.js"; import { currentClaudeSessionCatalogConfig, listBoundClaudeSessions, @@ -796,10 +797,6 @@ function unwrapNodePayload(value: unknown): unknown { return value; } -function nodeLabel(node: { displayName?: string; remoteIp?: string; nodeId: string }): string { - return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; -} - function parseGatewayQuery(value: unknown): { search?: string; limitPerHost: number; @@ -901,7 +898,7 @@ export async function listClaudeSessionCatalog(params: { let nodes: Awaited>["nodes"]; try { nodes = (await params.runtime.nodes.list()).nodes; - } catch { + } catch (error) { return { hosts: [ ...hosts, @@ -911,7 +908,7 @@ export async function listClaudeSessionCatalog(params: { kind: "node", connected: false, sessions: [], - error: { code: "NODE_LIST_FAILED", message: "Paired nodes could not be listed" }, + error: createNodeListFailedError(error), }, ], }; @@ -923,13 +920,13 @@ export async function listClaudeSessionCatalog(params: { (!requested || requested.has(`node:${node.nodeId}`)), ) .slice(0, MAX_HOSTS - hosts.length) - .toSorted((left, right) => nodeLabel(left).localeCompare(nodeLabel(right))); + .toSorted((left, right) => resolveNodeLabel(left).localeCompare(resolveNodeLabel(right))); const nodeHosts = await Promise.all( eligible.map(async (node): Promise => { const hostId = `node:${node.nodeId}`; const common = { hostId, - label: nodeLabel(node), + label: resolveNodeLabel(node), kind: "node" as const, connected: node.connected === true, nodeId: node.nodeId, @@ -1027,7 +1024,7 @@ async function readClaudeSessionTranscript(params: { } return { hostId: params.hostId, - label: nodeLabel(node), + label: resolveNodeLabel(node), threadId: params.threadId, items: page.items as ClaudeTranscriptItem[], ...(optionalString(page.nextCursor, MAX_CURSOR_LENGTH) diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 61faef715d42..6f5bd1d078fe 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -138,6 +138,42 @@ describe("codex plugin", () => { expect(typeof bindingResolvedRegistration?.[0]).toBe("function"); }); + it("lets native session discovery be disabled without disabling the Codex plugin", () => { + const registerAgentHarness = vi.fn(); + const registerNodeHostCommand = vi.fn(); + const registerProvider = vi.fn(); + const registerSessionCatalog = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "codex", + name: "Codex", + source: "test", + config: {}, + pluginConfig: { sessionCatalog: { enabled: false } }, + runtime: createCodexTestRuntime(), + registerAgentHarness, + registerCommand: vi.fn(), + registerMediaUnderstandingProvider: vi.fn(), + registerMigrationProvider: vi.fn(), + registerNodeHostCommand, + registerProvider, + registerSessionCatalog, + registerTool: vi.fn(), + on: vi.fn(), + }), + ); + + expect(registerAgentHarness).toHaveBeenCalledOnce(); + expect(registerProvider).toHaveBeenCalledOnce(); + const nodeCommands = registerNodeHostCommand.mock.calls.map( + ([command]) => (command as { command: string }).command, + ); + expect(nodeCommands).toEqual(["codex.cli.sessions.list", "codex.cli.session.resume"]); + expect(nodeCommands).not.toContain("codex.appServer.threads.list.v1"); + expect(nodeCommands).not.toContain("codex.appServer.thread.turns.list.v1"); + expect(registerSessionCatalog).not.toHaveBeenCalled(); + }); + it("registers the five shipped supervision tools only when supervision is enabled", () => { const registerTool = vi.fn(); plugin.register( diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index 8cd6fc473ca8..c23660207aa2 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -98,14 +98,18 @@ export default definePluginEntry({ getPluginConfig: resolveCurrentPluginConfig, getRuntimeConfig: resolveCurrentConfig, }); - codexSessionCatalogRuntime.register({ - api, - bindingStore, - control: sessionCatalogControl, - getRuntimeConfig: resolveCurrentConfig, - }); - for (const command of createCodexSessionCatalogNodeHostCommands(sessionCatalogControl)) { - api.registerNodeHostCommand(command); + const sessionCatalogEnabled = + readCodexPluginConfig(resolveCurrentPluginConfig()).sessionCatalog?.enabled !== false; + if (sessionCatalogEnabled) { + codexSessionCatalogRuntime.register({ + api, + bindingStore, + control: sessionCatalogControl, + getRuntimeConfig: resolveCurrentConfig, + }); + for (const command of createCodexSessionCatalogNodeHostCommands(sessionCatalogControl)) { + api.registerNodeHostCommand(command); + } } for (const policy of createCodexSessionCatalogNodeInvokePolicies()) { api.registerNodeInvokePolicy(policy); diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 1c48b71adf2a..786b51e81e41 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -31,7 +31,10 @@ "onStartup": false, "onAgentHarnesses": ["codex"], "onCommands": ["codex"], - "onConfigPaths": ["plugins.entries.codex.config.supervision.enabled"] + "onConfigPaths": [ + "plugins.entries.codex.config.sessionCatalog.enabled", + "plugins.entries.codex.config.supervision.enabled" + ] }, "commandAliases": [ { @@ -54,6 +57,13 @@ "items": { "type": "string" }, "default": [] }, + "sessionCatalog": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + }, "discovery": { "type": "object", "additionalProperties": false, @@ -354,6 +364,14 @@ } }, "uiHints": { + "sessionCatalog": { + "label": "Native Session Discovery", + "help": "Controls whether Codex sessions from this Gateway and paired nodes appear in the sidebar. Disabling also removes this host's paired-node catalog commands without disabling the Codex provider or harness. Node-list failures usually indicate a Gateway pairing-store or node-registry problem; verify Settings > Devices or run openclaw nodes list. Changes require a Gateway restart." + }, + "sessionCatalog.enabled": { + "label": "Discover Codex Sessions", + "help": "List native Codex sessions in the sidebar from this Gateway and eligible paired nodes." + }, "codexDynamicToolsLoading": { "label": "Dynamic Tools Loading", "help": "Use searchable to defer OpenClaw dynamic tools behind Codex tool search, or direct to expose them in the initial context.", diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index e7bc209d975b..0322a93a3121 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -353,6 +353,12 @@ describe("Codex app-server config", () => { ).toStrictEqual({}); }); + it("parses the native session discovery toggle", () => { + expect(readCodexPluginConfig({ sessionCatalog: { enabled: false } }).sessionCatalog).toEqual({ + enabled: false, + }); + }); + it("rejects unknown app-server fields", () => { expect( readCodexPluginConfig({ diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index e75fb99d9cba..d26b1129eede 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -29,6 +29,10 @@ import type { JsonObject, JsonValue, } from "./protocol.js"; +import { + codexDiscoveryConfigSchema, + codexSessionCatalogConfigSchema, +} from "./session-discovery-config.js"; const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret"); const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret(); @@ -256,10 +260,8 @@ type CodexModelBackedReviewerContext = { export type CodexPluginConfig = { codexDynamicToolsLoading?: CodexDynamicToolsLoading; codexDynamicToolsExclude?: string[]; - discovery?: { - enabled?: boolean; - timeoutMs?: number; - }; + sessionCatalog?: z.infer; + discovery?: z.infer; computerUse?: CodexComputerUseConfig; codexPlugins?: CodexPluginsConfig; supervision?: CodexSupervisionConfig; @@ -421,13 +423,8 @@ const codexPluginConfigSchema = z .object({ codexDynamicToolsLoading: codexDynamicToolsLoadingSchema.optional(), codexDynamicToolsExclude: z.array(z.string()).optional(), - discovery: z - .object({ - enabled: z.boolean().optional(), - timeoutMs: z.number().positive().optional(), - }) - .strict() - .optional(), + sessionCatalog: codexSessionCatalogConfigSchema.optional(), + discovery: codexDiscoveryConfigSchema.optional(), computerUse: z .object({ enabled: z.boolean().optional(), diff --git a/extensions/codex/src/app-server/session-discovery-config.ts b/extensions/codex/src/app-server/session-discovery-config.ts new file mode 100644 index 000000000000..53153ab22611 --- /dev/null +++ b/extensions/codex/src/app-server/session-discovery-config.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const codexSessionCatalogConfigSchema = z + .object({ enabled: z.boolean().optional() }) + .strict(); + +export const codexDiscoveryConfigSchema = z + .object({ + enabled: z.boolean().optional(), + timeoutMs: z.number().positive().optional(), + }) + .strict(); diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index 584a9fee4458..cbc9c5a1f611 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -424,13 +424,31 @@ export function unwrapNodeInvokePayload(value: unknown): unknown { return "payload" in value ? value.payload : value; } -export function catalogError(code: string, _error: unknown): CodexSessionCatalogError { +function catalogErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.message.trim(); + } + if (typeof error === "string") { + return error.trim(); + } + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + return typeof message === "string" ? message.trim() : ""; + } + return ""; +} + +export function catalogError(code: string, error: unknown): CodexSessionCatalogError { const messages: Record = { APP_SERVER_UNAVAILABLE: "Codex app-server is unavailable on this host", NODE_INVOKE_FAILED: "The paired node could not return its Codex session catalog", NODE_LIST_FAILED: "Paired nodes could not be listed", }; - return { code, message: messages[code] ?? "Codex session catalog request failed" }; + const summary = messages[code] ?? "Codex session catalog request failed"; + // Node-list failures are operator diagnostics from the local Gateway. Other + // catalog errors may cross node/App Server boundaries and keep their bounded summary. + const detail = code === "NODE_LIST_FAILED" ? catalogErrorDetail(error) : ""; + return { code, message: detail && detail !== summary ? `${summary}: ${detail}` : summary }; } export function parseTranscriptPage(value: unknown): CodexThreadTurnsListResponse { diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 0d26814fe26d..603a9d94b4fc 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -17,6 +17,7 @@ import { type CodexAppServerBindingStore, type CodexAppServerThreadBinding, } from "./app-server/session-binding.test-helpers.js"; +import { catalogError } from "./session-catalog-parsing.js"; import { CODEX_LOCAL_SESSION_HOST_ID, CODEX_TERMINAL_RESUME_COMMAND, @@ -369,6 +370,15 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); +describe("Codex session catalog errors", () => { + it("keeps the underlying paired-node list failure", () => { + expect(catalogError("NODE_LIST_FAILED", new Error("paired store is unreadable"))).toEqual({ + code: "NODE_LIST_FAILED", + message: "Paired nodes could not be listed: paired store is unreadable", + }); + }); +}); + describe("Codex supervision catalog", () => { it("lists non-archived interactive threads without probing transcript previews", async () => { const pluginConfig = { supervision: { enabled: true } }; diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 34e5ec4a5df0..31d3658c98cc 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -153,8 +153,10 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { const hasMore = hosts.some((host) => Boolean(host.nextCursor)); const canCreateSession = catalog.capabilities.createSession !== undefined; const errorMessages = [ - ...(catalog.error ? [catalog.error.message] : []), - ...hosts.flatMap((host) => (host.error ? [host.error.message] : [])), + ...(catalog.error ? [`[${catalog.error.code}] ${catalog.error.message}`] : []), + ...hosts.flatMap((host) => + host.error ? [`[${host.error.code}] ${host.error.message}`] : [], + ), ]; const hasError = errorMessages.length > 0; // Keep provider failures distinguishable from successful empty results. @@ -163,6 +165,7 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { return nothing; } const errorMessage = errorMessages.join("; "); + const errorHelp = `${errorMessage}. Configure native session discovery in Settings > Automation > Plugins.`; return html`