mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): require admin for native controls (#97952)
* fix(codex): require admin for native controls Gate Codex native session controls and bound turns on current owner or operator.admin authority. Preserve gateway scope precedence and read-only status behavior. * fix(codex): align native authorization * fix(codex): preserve silent bound handling * fix(codex): narrow bound auth contract * fix(docs): refresh generated docs map
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof handleCodexConversationInboundClaimImpl>
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -91,6 +91,7 @@ export type PluginHookInboundClaimEvent = {
|
||||
parentSpanId?: string;
|
||||
isGroup: boolean;
|
||||
commandAuthorized?: boolean;
|
||||
senderIsOwner?: boolean;
|
||||
wasMentioned?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user