Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx

### Changes

- Runtime/embedding: add an optional `onPermissionRequest` callback to `AcpRuntimeOptions` and `AcpClientOptions` so embedders can intercept ACP per-call permission requests with their own UI. Returning a decision short-circuits the mode-based resolver; returning `undefined` falls through to it, leaving CLI behavior unchanged.

### Breaking

### Fixes
Expand Down
58 changes: 57 additions & 1 deletion src/acp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ import {
PermissionPromptUnavailableError,
} from "../errors.js";
import { FileSystemHandlers } from "../filesystem.js";
import { classifyPermissionDecision, resolvePermissionRequest } from "../permissions.js";
import {
classifyPermissionDecision,
decisionToResponse,
inferToolKind,
resolvePermissionRequest,
} from "../permissions.js";
import { textPrompt } from "../prompt-content.js";
import { extractRuntimeSessionId } from "../session/runtime-session-id.js";
import { buildSpawnCommandOptions } from "../spawn-command-options.js";
Expand Down Expand Up @@ -267,6 +272,7 @@ export class AcpClient {
promise: Promise<PromptResponse>;
};
private readonly cancellingSessionIds = new Set<string>();
private readonly permissionAbortControllers = new Map<string, AbortController>();
private closing = false;
private agentStartedAt?: string;
private lastAgentExit?: AgentExitInfo;
Expand Down Expand Up @@ -769,6 +775,7 @@ export class AcpClient {
this.activePrompt = undefined;
}
this.cancellingSessionIds.delete(sessionId);
this.abortAndDropPermissionSignal(sessionId);
this.promptPermissionFailures.delete(sessionId);
}
}
Expand Down Expand Up @@ -848,6 +855,7 @@ export class AcpClient {
async cancel(sessionId: string): Promise<void> {
const connection = this.getConnection();
this.cancellingSessionIds.add(sessionId);
this.abortAndDropPermissionSignal(sessionId);
await this.runConnectionRequest(() =>
connection.cancel({
sessionId,
Expand Down Expand Up @@ -946,6 +954,10 @@ export class AcpClient {
this.suppressReplaySessionUpdateMessages = false;
this.activePrompt = undefined;
this.cancellingSessionIds.clear();
for (const controller of this.permissionAbortControllers.values()) {
controller.abort();
}
this.permissionAbortControllers.clear();
this.promptPermissionFailures.clear();
this.loadedSessionId = undefined;
this.initResult = undefined;
Expand Down Expand Up @@ -1204,6 +1216,33 @@ export class AcpClient {
};
}

if (this.options.onPermissionRequest) {
const signal = this.cancellationSignalForSession(params.sessionId);
try {
const decision = await this.options.onPermissionRequest(
{
sessionId: params.sessionId,
raw: params,
inferredKind: inferToolKind(params),
},
{ signal },
);
if (decision) {
const response = decisionToResponse(params, decision);
this.recordPermissionDecision(classifyPermissionDecision(params, response));
return response;
}
} catch (error) {
// Fall through to the mode-based resolver so a host UI error
// doesn't take down the turn.
this.log(
`onPermissionRequest threw, falling through to mode-based resolver: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

let response: RequestPermissionResponse;
try {
response = await resolvePermissionRequest(
Expand Down Expand Up @@ -1377,6 +1416,23 @@ export class AcpClient {
return await this.terminalManager.releaseTerminal(params);
}

private cancellationSignalForSession(sessionId: string): AbortSignal {
let controller = this.permissionAbortControllers.get(sessionId);
if (!controller) {
controller = new AbortController();
this.permissionAbortControllers.set(sessionId, controller);
}
return controller.signal;
}

private abortAndDropPermissionSignal(sessionId: string): void {
const controller = this.permissionAbortControllers.get(sessionId);
if (controller) {
controller.abort();
this.permissionAbortControllers.delete(sessionId);
}
}

private recordPermissionDecision(decision: "approved" | "denied" | "cancelled"): void {
this.permissionStats.requested += 1;
if (decision === "approved") {
Expand Down
29 changes: 27 additions & 2 deletions src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
} from "@agentclientprotocol/sdk";
import { PermissionPromptUnavailableError } from "./errors.js";
import { promptForPermission } from "./permission-prompt.js";
import type { NonInteractivePermissionPolicy, PermissionMode } from "./types.js";
import type {
AcpPermissionDecision,
NonInteractivePermissionPolicy,
PermissionMode,
} from "./types.js";

type PermissionDecision = "approved" | "denied" | "cancelled";
const PERMISSION_MODE_RANK: Record<PermissionMode, number> = {
Expand Down Expand Up @@ -36,7 +40,7 @@ function pickOption(
return undefined;
}

function inferToolKind(params: RequestPermissionRequest): ToolKind | undefined {
export function inferToolKind(params: RequestPermissionRequest): ToolKind | undefined {
if (params.toolCall.kind) {
return params.toolCall.kind;
}
Expand Down Expand Up @@ -151,6 +155,27 @@ export async function resolvePermissionRequest(
return cancelled();
}

const DECISION_FALLBACK_ORDER: Record<
Exclude<AcpPermissionDecision["outcome"], "cancel">,
PermissionOption["kind"][]
> = {
allow_once: ["allow_once", "allow_always"],
allow_always: ["allow_always", "allow_once"],
reject_once: ["reject_once", "reject_always"],
reject_always: ["reject_always", "reject_once"],
};

export function decisionToResponse(
params: RequestPermissionRequest,
decision: AcpPermissionDecision,
): RequestPermissionResponse {
if (decision.outcome === "cancel") {
return cancelled();
}
const matched = pickOption(params.options ?? [], DECISION_FALLBACK_ORDER[decision.outcome]);
return matched ? selected(matched.optionId) : cancelled();
}

export function classifyPermissionDecision(
params: RequestPermissionRequest,
response: RequestPermissionResponse,
Expand Down
2 changes: 2 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export {
export type {
AcpAgentRegistry,
AcpFileSessionStoreOptions,
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/engine/connected-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { AcpClient } from "../../acp/client.js";
import { withInterrupt } from "../../async-control.js";
import { absolutePath, isoNow } from "../../session/persistence.js";
import type {
AcpPermissionDecision,
AcpPermissionRequest,
AuthPolicy,
McpServer,
NonInteractivePermissionPolicy,
Expand Down Expand Up @@ -39,6 +41,10 @@ export type WithConnectedSessionOptions<T> = {
mcpServers?: McpServer[];
permissionMode?: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
onPermissionRequest?: (
req: AcpPermissionRequest,
ctx: { signal: AbortSignal },
) => Promise<AcpPermissionDecision | undefined>;
authCredentials?: Record<string, string>;
authPolicy?: AuthPolicy;
terminal?: boolean;
Expand Down Expand Up @@ -90,6 +96,7 @@ export async function withConnectedSession<T>(
mcpServers: options.mcpServers,
permissionMode: options.permissionMode ?? "approve-reads",
nonInteractivePermissions: options.nonInteractivePermissions,
onPermissionRequest: options.onPermissionRequest,
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
terminal: options.terminal,
Expand All @@ -102,6 +109,7 @@ export async function withConnectedSession<T>(
mcpServers: options.mcpServers,
permissionMode: options.permissionMode ?? "approve-reads",
nonInteractivePermissions: options.nonInteractivePermissions,
onPermissionRequest: options.onPermissionRequest,
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
terminal: options.terminal,
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/engine/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export class AcpRuntimeManager {
mcpServers: [...(this.options.mcpServers ?? [])],
permissionMode: this.options.permissionMode,
nonInteractivePermissions: this.options.nonInteractivePermissions,
onPermissionRequest: this.options.onPermissionRequest,
verbose: this.options.verbose,
timeoutMs: this.options.timeoutMs,
resumePolicy: resumePolicyForSessionMode(sessionMode),
Expand Down Expand Up @@ -385,6 +386,7 @@ export class AcpRuntimeManager {
mcpServers: [...(this.options.mcpServers ?? [])],
permissionMode: this.options.permissionMode,
nonInteractivePermissions: this.options.nonInteractivePermissions,
onPermissionRequest: this.options.onPermissionRequest,
verbose: this.options.verbose,
});
let keepClientOpen = false;
Expand Down Expand Up @@ -532,6 +534,7 @@ export class AcpRuntimeManager {
mcpServers: [...(this.options.mcpServers ?? [])],
permissionMode: this.options.permissionMode,
nonInteractivePermissions: this.options.nonInteractivePermissions,
onPermissionRequest: this.options.onPermissionRequest,
verbose: this.options.verbose,
});
const runtimeClient = client;
Expand Down Expand Up @@ -897,6 +900,7 @@ export class AcpRuntimeManager {
mcpServers: [...(this.options.mcpServers ?? [])],
permissionMode: this.options.permissionMode,
nonInteractivePermissions: this.options.nonInteractivePermissions,
onPermissionRequest: this.options.onPermissionRequest,
verbose: this.options.verbose,
});

Expand Down
8 changes: 8 additions & 0 deletions src/runtime/public/contract.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type {
AcpPermissionDecision,
AcpPermissionRequest,
McpServer,
NonInteractivePermissionPolicy,
PermissionMode,
SessionRecord,
} from "../../types.js";

export type { AcpPermissionDecision, AcpPermissionRequest } from "../../types.js";

export type AcpRuntimePromptMode = "prompt" | "steer";

export type AcpRuntimeSessionMode = "persistent" | "oneshot";
Expand Down Expand Up @@ -195,6 +199,10 @@ export type AcpRuntimeOptions = {
timeoutMs?: number;
probeAgent?: string;
verbose?: boolean;
onPermissionRequest?: (
req: AcpPermissionRequest,
ctx: { signal: AbortSignal },
) => Promise<AcpPermissionDecision | undefined>;
};

export type AcpFileSessionStoreOptions = {
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@ import type {
AgentCapabilities,
AnyMessage,
McpServer,
RequestPermissionRequest,
SessionNotification,
SessionConfigOption,
SetSessionConfigOptionResponse,
StopReason,
ToolKind,
} from "@agentclientprotocol/sdk";
export type { McpServer, SessionNotification } from "@agentclientprotocol/sdk";
import type { PromptInput } from "./prompt-content.js";

export type AcpPermissionRequest = {
sessionId: string;
raw: RequestPermissionRequest;
inferredKind: ToolKind | undefined;
};

export type AcpPermissionDecision =
| { outcome: "allow_once" }
| { outcome: "allow_always" }
| { outcome: "reject_once" }
| { outcome: "reject_always" }
| { outcome: "cancel" };

export const EXIT_CODES = {
SUCCESS: 0,
ERROR: 1,
Expand Down Expand Up @@ -180,6 +195,10 @@ export type AcpClientOptions = {
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
onClientOperation?: (operation: ClientOperation) => void;
onPermissionRequest?: (
req: AcpPermissionRequest,
ctx: { signal: AbortSignal },
) => Promise<AcpPermissionDecision | undefined>;
};

export const SESSION_RECORD_SCHEMA = "acpx.session.v1" as const;
Expand Down
Loading