diff --git a/docs/docs_map.md b/docs/docs_map.md index a42ad86a62b9..76255cff4e87 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -8448,6 +8448,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What the wizard writes - H2: Related docs +## releases/index.md + +- Route: /releases +- Headings: + - H1: Release notes + - H2: Coming soon + - H2: Raw release history + ## security/CONTRIBUTING-THREAT-MODEL.md - Route: /security/CONTRIBUTING-THREAT-MODEL diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index a67c120e6d37..e89d2fa94e5d 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -404,6 +404,12 @@ timeout behavior, see [Codex harness reference](/plugins/codex-harness-reference The bundled plugin registers `/codex` as a slash command on any channel that supports OpenClaw text commands. +Native execution and control require an owner or an `operator.admin` Gateway +client. This includes binding or resuming threads, sending or stopping turns, +changing model, fast-mode, or permission state, compacting or reviewing, and +detaching a binding. Other authorized senders retain read-only status, help, +account, model, thread, MCP server, skill, and binding inspection commands. + Common forms: - `/codex status` checks app-server connectivity, models, account, rate limits, diff --git a/extensions/codex/src/command-authorization.ts b/extensions/codex/src/command-authorization.ts index ef0846c182d9..397bde16c600 100644 --- a/extensions/codex/src/command-authorization.ts +++ b/extensions/codex/src/command-authorization.ts @@ -1,5 +1,13 @@ import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -export function canMutateCodexHost(ctx: PluginCommandContext): boolean { +type CodexHostMutationAuthContext = Pick< + PluginCommandContext, + "gatewayClientScopes" | "senderIsOwner" +>; + +export const CODEX_NATIVE_EXECUTION_AUTH_ERROR = + "Only an owner or operator.admin can control Codex native execution."; + +export function canMutateCodexHost(ctx: CodexHostMutationAuthContext): boolean { return ctx.senderIsOwner === true || ctx.gatewayClientScopes?.includes("operator.admin") === true; } diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 900957217ebe..2681c787d173 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -24,7 +24,7 @@ import { writeCodexAppServerBinding, } from "./app-server/session-binding.js"; import { readCodexAccountAuthOverview } from "./command-account.js"; -import { canMutateCodexHost } from "./command-authorization.js"; +import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js"; import { buildHelp, formatAccount, @@ -233,6 +233,12 @@ const CODEX_NATIVE_EXECUTION_SUBCOMMANDS = new Set([ "compact", "review", ]); +const CODEX_NATIVE_CONTROL_SUBCOMMANDS = new Set([ + ...CODEX_NATIVE_EXECUTION_SUBCOMMANDS, + "detach", + "unbind", + "stop", +]); const lastCodexDiagnosticsUploadByThread = new Map(); const lastCodexDiagnosticsUploadByScope = new Map(); @@ -378,6 +384,13 @@ export async function handleCodexSubcommand( if (normalized === "help") { return { text: buildHelp() }; } + if ( + CODEX_NATIVE_CONTROL_SUBCOMMANDS.has(normalized) && + !returnsBeforeNativeCodexExecution(normalized, rest) && + !canMutateCodexHost(ctx) + ) { + return { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR }; + } const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest); if (sandboxBlock) { return { text: sandboxBlock }; @@ -603,6 +616,8 @@ function returnsBeforeNativeCodexExecution(subcommand: string, args: readonly st ); case "compact": case "review": + case "detach": + case "unbind": case "stop": return args.length > 0; default: @@ -1000,15 +1015,15 @@ async function setConversationPermissions( if (args.length > 1) { return "Usage: /codex permissions [default|yolo|status]"; } - const target = await resolveControlTarget(ctx); - if (!target) { - return "Cannot set Codex permissions because this command did not include an OpenClaw session file."; - } const value = args[0]; const parsed = parseCodexPermissionsModeArg(value); if (value && !parsed && value.trim().toLowerCase() !== "status") { return "Usage: /codex permissions [default|yolo|status]"; } + const target = await resolveControlTarget(ctx); + if (!target) { + return "Cannot set Codex permissions because this command did not include an OpenClaw session file."; + } return await deps.setCodexConversationPermissions({ sessionFile: target.sessionFile, pluginConfig, diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index da35a8cdefac..e0f9c52c1bda 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -3878,6 +3878,106 @@ describe("codex command", () => { }); }); + it("requires an owner or operator.admin for Codex binding and permission changes", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const startCodexConversationThread = vi.fn(); + const codexControlRequest = vi.fn(); + const resolveCodexCliSessionForBindingOnNode = vi.fn(); + const stopCodexConversationTurn = vi.fn(); + const steerCodexConversationTurn = vi.fn(); + const setCodexConversationModel = vi.fn(); + const setCodexConversationFastMode = vi.fn(); + const setCodexConversationPermissions = vi.fn( + async () => "Codex permissions set to full access.", + ); + const cases = [ + ["bind", createDeps({ startCodexConversationThread }), startCodexConversationThread], + ["resume thread-123", createDeps({ codexControlRequest }), codexControlRequest], + [ + "resume cli-session --host worker-1 --bind here", + createDeps({ resolveCodexCliSessionForBindingOnNode }), + resolveCodexCliSessionForBindingOnNode, + ], + ["stop", createDeps({ stopCodexConversationTurn }), stopCodexConversationTurn], + [ + "permissions yolo", + createDeps({ setCodexConversationPermissions }), + setCodexConversationPermissions, + ], + ["steer continue", createDeps({ steerCodexConversationTurn }), steerCodexConversationTurn], + ["model gpt-5.5", createDeps({ setCodexConversationModel }), setCodexConversationModel], + ["fast on", createDeps({ setCodexConversationFastMode }), setCodexConversationFastMode], + ["compact", createDeps({ codexControlRequest }), codexControlRequest], + ["review", createDeps({ codexControlRequest }), codexControlRequest], + ] as const; + + for (const [args, deps, sideEffect] of cases) { + await expect( + handleCodexCommand( + createContext(args, sessionFile, { + senderIsOwner: false, + gatewayClientScopes: ["operator.write"], + }), + { deps }, + ), + ).resolves.toEqual({ + text: "Only an owner or operator.admin can control Codex native execution.", + }); + expect(sideEffect).not.toHaveBeenCalled(); + } + + const detachConversationBinding = vi.fn(); + for (const args of ["detach", "unbind"]) { + await expect( + handleCodexCommand( + createContext(args, sessionFile, { + senderIsOwner: false, + gatewayClientScopes: ["operator.write"], + detachConversationBinding, + }), + { deps: createDeps() }, + ), + ).resolves.toEqual({ + text: "Only an owner or operator.admin can control Codex native execution.", + }); + } + expect(detachConversationBinding).not.toHaveBeenCalled(); + + const readCodexPermissions = vi.fn(async () => "Codex permissions: full access."); + await expect( + handleCodexCommand( + createContext("permissions status", sessionFile, { + senderIsOwner: false, + gatewayClientScopes: ["operator.write"], + }), + { deps: createDeps({ setCodexConversationPermissions: readCodexPermissions }) }, + ), + ).resolves.toEqual({ text: "Codex permissions: full access." }); + expect(readCodexPermissions).toHaveBeenCalledTimes(1); + + await expect( + handleCodexCommand( + createContext("permissions yolo", sessionFile, { + senderIsOwner: true, + gatewayClientScopes: ["operator.write"], + }), + { deps: createDeps({ setCodexConversationPermissions }) }, + ), + ).resolves.toEqual({ text: "Codex permissions set to full access." }); + expect(setCodexConversationPermissions).toHaveBeenCalledTimes(1); + + await expect( + handleCodexCommand( + createContext("permissions yolo", sessionFile, { + senderIsOwner: false, + gatewayClientScopes: ["operator.admin"], + }), + { deps: createDeps({ setCodexConversationPermissions }) }, + ), + ).resolves.toEqual({ text: "Codex permissions set to full access." }); + expect(setCodexConversationPermissions).toHaveBeenCalledTimes(2); + }); + it("escapes current bound model status before chat display", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index 22e32614f063..94477493d841 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -73,10 +73,16 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks); import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js"; import { handleCodexConversationBindingResolved, - handleCodexConversationInboundClaim, + handleCodexConversationInboundClaim as handleCodexConversationInboundClaimImpl, startCodexConversationThread, } from "./conversation-binding.js"; +function handleCodexConversationInboundClaim( + ...[event, ...rest]: Parameters +) { + return handleCodexConversationInboundClaimImpl({ senderIsOwner: true, ...event }, ...rest); +} + let tempDir: string; const NETWORK_PROXY_PLUGIN_CONFIG = { @@ -574,6 +580,7 @@ describe("codex conversation binding", () => { content: "run this", channel: "discord", isGroup: true, + senderIsOwner: false, }, { channelId: "discord", @@ -598,6 +605,42 @@ describe("codex conversation binding", () => { expect(result).toEqual({ handled: true }); }); + it("blocks inbound bound turns without current owner or admin authority", async () => { + const result = await handleCodexConversationInboundClaim( + { + content: "run this", + channel: "discord", + isGroup: true, + commandAuthorized: true, + senderIsOwner: false, + }, + { + channelId: "discord", + pluginBinding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: tempDir, + channel: "discord", + accountId: "default", + conversationId: "channel-1", + boundAt: Date.now(), + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile: path.join(tempDir, "session.jsonl"), + workspaceDir: tempDir, + }, + }, + }, + ); + + expect(result).toEqual({ + handled: true, + reply: { text: "Only an owner or operator.admin can control Codex native execution." }, + }); + expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled(); + }); + it("routes bound Codex CLI node sessions through node resume", async () => { const resumeCodexCliSessionOnNode = vi.fn(async () => ({ ok: true as const, diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index be1d743987aa..5c3af84e821c 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -53,6 +53,7 @@ import { CODEX_NATIVE_PERSONALITY_NONE, resolveCodexAppServerRequestModelSelection, } from "./app-server/thread-lifecycle.js"; +import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js"; import { formatCodexDisplayText } from "./command-formatters.js"; import { createCodexConversationBindingData, @@ -244,6 +245,9 @@ export async function handleCodexConversationInboundClaim( if (!prompt) { return { handled: true }; } + if (!canMutateCodexHost(event)) { + return { handled: true, reply: { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR } }; + } const nativeExecutionBlock = data.kind === "codex-cli-node-session" ? resolveCodexNativeSandboxBlock({ diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index c5ea367a97f4..bdf04f50d761 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -6576,6 +6576,8 @@ describe("dispatchReplyFromConfig", () => { AccountId: "default", SenderId: "user-9", SenderUsername: "ada", + OwnerAllowFrom: ["user-9"], + GatewayClientScopes: ["operator.write"], CommandAuthorized: true, WasMentioned: false, CommandBody: "who are you", @@ -6594,7 +6596,13 @@ describe("dispatchReplyFromConfig", () => { .calls[0] as unknown as | [ unknown, - { accountId?: unknown; channel?: unknown; content?: unknown; conversationId?: unknown }, + { + accountId?: unknown; + channel?: unknown; + content?: unknown; + conversationId?: unknown; + senderIsOwner?: unknown; + }, { accountId?: unknown; channelId?: unknown; @@ -6608,6 +6616,8 @@ describe("dispatchReplyFromConfig", () => { expect(inboundClaimCall?.[1]?.accountId).toBe("default"); expect(inboundClaimCall?.[1]?.conversationId).toBe("channel:1481858418548412579"); expect(inboundClaimCall?.[1]?.content).toBe("who are you"); + expect(inboundClaimCall?.[1]?.senderIsOwner).toBe(true); + expect(inboundClaimCall?.[1]).not.toHaveProperty("gatewayClientScopes"); expect(inboundClaimCall?.[2]?.channelId).toBe("discord"); expect(inboundClaimCall?.[2]?.accountId).toBe("default"); expect(inboundClaimCall?.[2]?.conversationId).toBe("channel:1481858418548412579"); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 0028455863c9..7c04f517264f 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -104,6 +104,7 @@ import { shouldAttemptTtsPayload, } from "../../tts/tts-config.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import { isNativeCommandTurn, resolveCommandTurnContext, @@ -2148,12 +2149,23 @@ export async function dispatchReplyFromConfig( logVerbose( `plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`, ); + // Bound native runtimes need the current owner decision, not stale bind-time identity. + // The resolver folds internal operator.admin authority into this owner decision. + const bindingAuthorization = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: ctx.CommandAuthorized, + }); const targetedClaimOutcome = hookRunner?.runInboundClaimForPluginOutcome ? await (async () => { await prepareHookMediaMetadata(); + const authorizedInboundClaimEvent = { + ...inboundClaimEvent, + senderIsOwner: bindingAuthorization.senderIsOwner, + }; return hookRunner.runInboundClaimForPluginOutcome( pluginOwnedBinding.pluginId, - inboundClaimEvent, + authorizedInboundClaimEvent, { ...inboundClaimContext, pluginBinding: pluginOwnedBinding }, ); })() diff --git a/src/plugins/hook-message.types.ts b/src/plugins/hook-message.types.ts index 4b5e775b7ac3..24b1ca650d91 100644 --- a/src/plugins/hook-message.types.ts +++ b/src/plugins/hook-message.types.ts @@ -91,6 +91,7 @@ export type PluginHookInboundClaimEvent = { parentSpanId?: string; isGroup: boolean; commandAuthorized?: boolean; + senderIsOwner?: boolean; wasMentioned?: boolean; metadata?: Record; };