Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/tools/acp-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,12 @@ If no target resolves, OpenClaw returns a clear error
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |

Runtime controls (`spawn`, `cancel`, `steer`, `close`, `status`, `set-mode`,
`set`, `cwd`, `permissions`, `timeout`, `model`, and `reset-options`) require
owner identity from external channels and `operator.admin` from internal Gateway
clients. Authorized non-owner senders can still use `sessions`, `doctor`,
`install`, and `help`.

`/acp status` shows the effective runtime options plus runtime-level and
backend-level session identifiers. Unsupported-control errors surface
clearly when a backend lacks a capability. `/acp sessions` reads the
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ plugins.
| Command | Description |
| --- | --- |
| `/subagents list\|log\|info` | Inspect sub-agent runs for the current session |
| `/acp spawn\|cancel\|steer\|close\|sessions\|status\|set-mode\|set\|cwd\|permissions\|timeout\|model\|reset-options\|doctor\|install\|help` | Manage ACP sessions and runtime options |
| `/acp spawn\|cancel\|steer\|close\|sessions\|status\|set-mode\|set\|cwd\|permissions\|timeout\|model\|reset-options\|doctor\|install\|help` | Manage ACP sessions and runtime options. Runtime controls require external owner or internal Gateway admin identity |
| `/focus <target>` | Bind the current Discord thread or Telegram topic to a session target |
| `/unfocus` | Remove the current thread binding |
| `/agents` | List thread-bound agents for the current session |
Expand Down
34 changes: 33 additions & 1 deletion src/auto-reply/reply/commands-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
AccountId: "default",
});
params.command.senderId = "user-1";
params.command.senderIsOwner = true;
return params;
}

Expand Down Expand Up @@ -752,6 +753,7 @@ function createConversationParams(
...(fixture.threadParentId ? { ThreadParentId: fixture.threadParentId } : {}),
});
params.command.senderId = fixture.senderId ?? "user-1";
params.command.senderIsOwner = true;
return params;
}

Expand Down Expand Up @@ -896,7 +898,6 @@ async function runInternalAcpCommand(params: {
});
commandParams.command.channel = INTERNAL_MESSAGE_CHANNEL;
commandParams.command.senderId = "user-1";
commandParams.command.senderIsOwner = true;
return handleAcpCommand(commandParams, true);
}

Expand Down Expand Up @@ -1142,6 +1143,37 @@ describe("/acp command", () => {
expect(result?.reply?.text).toContain("/acp spawn");
});

it.each([
"spawn codex",
"cancel",
"steer continue",
"close",
"status",
"set-mode plan",
"set model gpt-5.5",
"cwd /tmp",
"permissions approve-all",
"timeout 120",
"model openai/gpt-5.5",
"reset-options",
])("blocks authorized non-owners from /acp %s", async (action) => {
const params = createDiscordParams(`/acp ${action}`);
params.command.senderIsOwner = false;

const result = await handleAcpCommand(params, true);

expect(result).toEqual({ shouldContinue: false });
});

it("keeps read-only /acp actions available to authorized non-owners", async () => {
const params = createDiscordParams("/acp sessions");
params.command.senderIsOwner = false;

const result = await handleAcpCommand(params, true);

expect(result?.reply?.text).toContain("ACP sessions:");
});

it("spawns an ACP session and binds a Discord thread", async () => {
hoisted.ensureSessionMock.mockResolvedValueOnce({
sessionKey: "agent:codex:acp:s1",
Expand Down
12 changes: 9 additions & 3 deletions src/auto-reply/reply/commands-acp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Implements ACP session commands and runtime status formatting.
import { logVerbose } from "../../globals.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { requireGatewayClientScope } from "./command-gates.js";
import { rejectNonOwnerCommand, requireGatewayClientScope } from "./command-gates.js";
import {
COMMAND,
type AcpAction,
Expand Down Expand Up @@ -71,7 +71,7 @@ async function loadAcpActionHandler(action: Exclude<AcpAction, "help">): Promise
return diagnosticHandlers[action];
}

const ACP_MUTATING_ACTIONS = new Set<AcpAction>([
const ACP_OWNER_REQUIRED_ACTIONS = new Set<AcpAction>([
"spawn",
"cancel",
"steer",
Expand Down Expand Up @@ -104,7 +104,7 @@ export const handleAcpCommand: CommandHandler = async (params, _allowTextCommand
return stopWithText(resolveAcpHelpText());
}

if (ACP_MUTATING_ACTIONS.has(action)) {
if (ACP_OWNER_REQUIRED_ACTIONS.has(action)) {
const scopeBlock = requireGatewayClientScope(params, {
label: "/acp",
allowedScopes: ["operator.admin"],
Expand All @@ -113,6 +113,12 @@ export const handleAcpCommand: CommandHandler = async (params, _allowTextCommand
if (scopeBlock) {
return scopeBlock;
}
// Command auth maps internal operator.admin scope to owner identity, so this
// second gate rejects external non-owners without blocking Gateway admins.
const nonOwner = rejectNonOwnerCommand(params, "/acp");
if (nonOwner) {
return nonOwner;
}
}

const handler = await loadAcpActionHandler(action);
Expand Down
Loading