Add ACP permission mode support to the SDK (#224)

* chore: recover hamburg workspace state

* chore: drop workspace context files

* refactor: generalize permissions example

* refactor: parse permissions example flags

* docs: clarify why fs and terminal stay native

* feat: add interactive permission prompt UI to Inspector

Add permission request handling to the Inspector UI so users can
Allow, Always Allow, or Reject tool calls that require permissions
instead of having them auto-cancelled. Wires up SDK
onPermissionRequest/respondPermission through App → ChatPanel →
ChatMessages with proper toolCallId-to-pendingId mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent permission reply from silently escalating "once" to "always"

Remove allow_always from the fallback chain when the user replies "once",
aligning with the ACP spec which says "map by option kind first" with no
fallback for allow_once. Also fix Inspector to use rawSend, revert
hydration guard to accept empty configOptions, and handle respondPermission
errors by rejecting the pending promise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-10 21:52:43 -07:00 committed by GitHub
parent 5d65013aa5
commit 76586f409f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1786 additions and 472 deletions

View file

@ -8,8 +8,12 @@ import {
type CancelNotification,
type NewSessionRequest,
type NewSessionResponse,
type PermissionOption,
type PermissionOptionKind,
type PromptRequest,
type PromptResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionConfigOption,
type SessionNotification,
type SessionModeState,
@ -142,9 +146,28 @@ export interface SessionSendOptions {
}
export type SessionEventListener = (event: SessionEvent) => void;
export type PermissionReply = "once" | "always" | "reject";
export type PermissionRequestListener = (request: SessionPermissionRequest) => void;
export type ProcessLogListener = (entry: ProcessLogEntry) => void;
export type ProcessLogFollowQuery = Omit<ProcessLogsQuery, "follow">;
export interface SessionPermissionRequestOption {
optionId: string;
name: string;
kind: PermissionOptionKind;
}
export interface SessionPermissionRequest {
id: string;
createdAt: number;
sessionId: string;
agentSessionId: string;
availableReplies: PermissionReply[];
options: SessionPermissionRequestOption[];
toolCall: RequestPermissionRequest["toolCall"];
rawRequest: RequestPermissionRequest;
}
export interface AgentQueryOptions {
config?: boolean;
noCache?: boolean;
@ -238,6 +261,22 @@ export class UnsupportedSessionConfigOptionError extends Error {
}
}
export class UnsupportedPermissionReplyError extends Error {
readonly permissionId: string;
readonly requestedReply: PermissionReply;
readonly availableReplies: PermissionReply[];
constructor(permissionId: string, requestedReply: PermissionReply, availableReplies: PermissionReply[]) {
super(
`Permission '${permissionId}' does not support reply '${requestedReply}'. Available replies: ${availableReplies.join(", ") || "(none)"}`,
);
this.name = "UnsupportedPermissionReplyError";
this.permissionId = permissionId;
this.requestedReply = requestedReply;
this.availableReplies = availableReplies;
}
}
export class Session {
private record: SessionRecord;
private readonly sandbox: SandboxAgent;
@ -280,14 +319,14 @@ export class Session {
return this;
}
async send(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
const updated = await this.sandbox.sendSessionMethod(this.id, method, params, options);
async rawSend(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
const updated = await this.sandbox.rawSendSessionMethod(this.id, method, params, options);
this.apply(updated.session.toRecord());
return updated.response;
}
async prompt(prompt: PromptRequest["prompt"]): Promise<PromptResponse> {
const response = await this.send("session/prompt", { prompt });
const response = await this.rawSend("session/prompt", { prompt });
return response as PromptResponse;
}
@ -327,6 +366,18 @@ export class Session {
return this.sandbox.onSessionEvent(this.id, listener);
}
onPermissionRequest(listener: PermissionRequestListener): () => void {
return this.sandbox.onPermissionRequest(this.id, listener);
}
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
await this.sandbox.respondPermission(permissionId, reply);
}
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
await this.sandbox.rawRespondPermission(permissionId, response);
}
toRecord(): SessionRecord {
return { ...this.record };
}
@ -355,6 +406,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void;
private readonly onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
private constructor(
agent: string,
@ -366,11 +423,18 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void,
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>,
) {
this.agent = agent;
this.connectionId = connectionId;
this.acp = acp;
this.onObservedEnvelope = onObservedEnvelope;
this.onPermissionRequest = onPermissionRequest;
}
static async create(options: {
@ -386,6 +450,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void;
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
}): Promise<LiveAcpConnection> {
const connectionId = randomId();
@ -400,6 +470,12 @@ export class LiveAcpConnection {
bootstrapQuery: { agent: options.agent },
},
client: {
requestPermission: async (request: RequestPermissionRequest) => {
if (!live) {
return cancelledPermissionResponse();
}
return live.handlePermissionRequest(request);
},
sessionUpdate: async (_notification: SessionNotification) => {
// Session updates are observed via envelope persistence.
},
@ -416,7 +492,13 @@ export class LiveAcpConnection {
},
});
live = new LiveAcpConnection(options.agent, connectionId, acp, options.onObservedEnvelope);
live = new LiveAcpConnection(
options.agent,
connectionId,
acp,
options.onObservedEnvelope,
options.onPermissionRequest,
);
const initResult = await acp.initialize({
protocolVersion: PROTOCOL_VERSION,
@ -550,6 +632,23 @@ export class LiveAcpConnection {
this.lastAdapterExitAt = Date.now();
}
private async handlePermissionRequest(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const agentSessionId = request.sessionId;
const localSessionId = this.localByAgentSessionId.get(agentSessionId);
if (!localSessionId) {
return cancelledPermissionResponse();
}
return this.onPermissionRequest(
this,
localSessionId,
agentSessionId,
clonePermissionRequest(request),
);
}
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
const id = envelopeId(envelope);
const method = envelopeMethod(envelope);
@ -782,6 +881,8 @@ export class SandboxAgent {
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
private readonly sessionHandles = new Map<string, Session>();
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
private readonly permissionListeners = new Map<string, Set<PermissionRequestListener>>();
private readonly pendingPermissionRequests = new Map<string, PendingPermissionRequestState>();
private readonly nextSessionEventIndexBySession = new Map<string, number>();
private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>();
@ -840,6 +941,11 @@ export class SandboxAgent {
this.disposed = true;
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed."));
for (const [permissionId, pending] of this.pendingPermissionRequests) {
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(cancelledPermissionResponse());
}
const connections = [...this.liveConnections.values()];
this.liveConnections.clear();
const pending = [...this.pendingLiveConnections.values()];
@ -984,6 +1090,8 @@ export class SandboxAgent {
}
async destroySession(id: string): Promise<Session> {
this.cancelPendingPermissionsForSession(id);
try {
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
} catch {
@ -1100,7 +1208,26 @@ export class SandboxAgent {
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
const record = await this.requireSessionRecord(sessionId);
return cloneModes(record.modes);
if (record.modes && record.modes.availableModes.length > 0) {
return cloneModes(record.modes);
}
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
if (hydrated.modes && hydrated.modes.availableModes.length > 0) {
return cloneModes(hydrated.modes);
}
const derived = deriveModesFromConfigOptions(hydrated.configOptions);
if (!derived) {
return cloneModes(hydrated.modes);
}
const updated: SessionRecord = {
...hydrated,
modes: derived,
};
await this.persist.updateSession(updated);
return cloneModes(derived);
}
private async setSessionCategoryValue(
@ -1155,7 +1282,7 @@ export class SandboxAgent {
return updated;
}
async sendSessionMethod(
async rawSendSessionMethod(
sessionId: string,
method: string,
params: Record<string, unknown>,
@ -1290,6 +1417,47 @@ export class SandboxAgent {
};
}
onPermissionRequest(sessionId: string, listener: PermissionRequestListener): () => void {
const listeners = this.permissionListeners.get(sessionId) ?? new Set<PermissionRequestListener>();
listeners.add(listener);
this.permissionListeners.set(sessionId, listeners);
return () => {
const set = this.permissionListeners.get(sessionId);
if (!set) {
return;
}
set.delete(listener);
if (set.size === 0) {
this.permissionListeners.delete(sessionId);
}
};
}
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
const pending = this.pendingPermissionRequests.get(permissionId);
if (!pending) {
throw new Error(`permission '${permissionId}' not found`);
}
let response: RequestPermissionResponse;
try {
response = permissionReplyToResponse(permissionId, pending.request, reply);
} catch (error) {
pending.reject(error instanceof Error ? error : new Error(String(error)));
this.pendingPermissionRequests.delete(permissionId);
throw error;
}
this.resolvePendingPermission(permissionId, response);
}
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
if (!this.pendingPermissionRequests.has(permissionId)) {
throw new Error(`permission '${permissionId}' not found`);
}
this.resolvePendingPermission(permissionId, clonePermissionResponse(response));
}
async getHealth(): Promise<HealthResponse> {
return this.requestHealth();
}
@ -1301,9 +1469,22 @@ export class SandboxAgent {
}
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
query: toAgentQuery(options),
});
try {
return await this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
query: toAgentQuery(options),
});
} catch (error) {
if (!(error instanceof SandboxAgentError) || error.status !== 404) {
throw error;
}
const listed = await this.listAgents(options);
const match = listed.agents.find((entry) => entry.id === agent);
if (match) {
return match;
}
throw error;
}
}
async installAgent(agent: string, request: AgentInstallRequest = {}): Promise<AgentInstallResponse> {
@ -1551,6 +1732,8 @@ export class SandboxAgent {
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
},
onPermissionRequest: async (connection, localSessionId, agentSessionId, request) =>
this.enqueuePermissionRequest(connection, localSessionId, agentSessionId, request),
});
const raced = this.liveConnections.get(agent);
@ -1753,6 +1936,69 @@ export class SandboxAgent {
return record;
}
private async enqueuePermissionRequest(
_connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const listeners = this.permissionListeners.get(localSessionId);
if (!listeners || listeners.size === 0) {
return cancelledPermissionResponse();
}
const pendingId = randomId();
const permissionRequest: SessionPermissionRequest = {
id: pendingId,
createdAt: nowMs(),
sessionId: localSessionId,
agentSessionId,
availableReplies: availablePermissionReplies(request.options),
options: request.options.map(clonePermissionOption),
toolCall: clonePermissionToolCall(request.toolCall),
rawRequest: clonePermissionRequest(request),
};
return await new Promise<RequestPermissionResponse>((resolve, reject) => {
this.pendingPermissionRequests.set(pendingId, {
id: pendingId,
sessionId: localSessionId,
request: clonePermissionRequest(request),
resolve,
reject,
});
try {
for (const listener of listeners) {
listener(permissionRequest);
}
} catch (error) {
this.pendingPermissionRequests.delete(pendingId);
reject(error);
}
});
}
private resolvePendingPermission(permissionId: string, response: RequestPermissionResponse): void {
const pending = this.pendingPermissionRequests.get(permissionId);
if (!pending) {
throw new Error(`permission '${permissionId}' not found`);
}
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(response);
}
private cancelPendingPermissionsForSession(sessionId: string): void {
for (const [permissionId, pending] of this.pendingPermissionRequests) {
if (pending.sessionId !== sessionId) {
continue;
}
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(cancelledPermissionResponse());
}
}
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.requestRaw(method, path, {
query: options.query,
@ -1922,6 +2168,14 @@ export class SandboxAgent {
}
}
type PendingPermissionRequestState = {
id: string;
sessionId: string;
request: RequestPermissionRequest;
resolve: (response: RequestPermissionResponse) => void;
reject: (reason?: unknown) => void;
};
type QueryValue = string | number | boolean | null | undefined;
type RequestOptions = {
@ -2166,6 +2420,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage {
return JSON.parse(JSON.stringify(envelope)) as AnyMessage;
}
function clonePermissionRequest(request: RequestPermissionRequest): RequestPermissionRequest {
return JSON.parse(JSON.stringify(request)) as RequestPermissionRequest;
}
function clonePermissionResponse(response: RequestPermissionResponse): RequestPermissionResponse {
return JSON.parse(JSON.stringify(response)) as RequestPermissionResponse;
}
function clonePermissionOption(option: PermissionOption): SessionPermissionRequestOption {
return {
optionId: option.optionId,
name: option.name,
kind: option.kind,
};
}
function clonePermissionToolCall(toolCall: RequestPermissionRequest["toolCall"]): RequestPermissionRequest["toolCall"] {
return JSON.parse(JSON.stringify(toolCall)) as RequestPermissionRequest["toolCall"];
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null;
}
@ -2314,6 +2588,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string
.filter((value): value is string => !!value);
}
function deriveModesFromConfigOptions(
configOptions: SessionConfigOption[] | undefined,
): SessionModeState | null {
if (!configOptions || configOptions.length === 0) {
return null;
}
const modeOption = findConfigOptionByCategory(configOptions, "mode");
if (!modeOption || !Array.isArray(modeOption.options)) {
return null;
}
const availableModes = modeOption.options
.flatMap((entry) => flattenConfigOptions(entry))
.map((entry) => ({
id: entry.value,
name: entry.name,
description: entry.description ?? null,
}));
return {
currentModeId:
typeof modeOption.currentValue === "string" && modeOption.currentValue.length > 0
? modeOption.currentValue
: availableModes[0]?.id ?? "",
availableModes,
};
}
function applyCurrentMode(
modes: SessionModeState | null | undefined,
currentModeId: string,
@ -2344,6 +2647,25 @@ function applyConfigOptionValue(
return updated;
}
function flattenConfigOptions(entry: unknown): Array<{ value: string; name: string; description?: string }> {
if (!isRecord(entry)) {
return [];
}
if (typeof entry.value === "string" && typeof entry.name === "string") {
return [
{
value: entry.value,
name: entry.name,
description: typeof entry.description === "string" ? entry.description : undefined,
},
];
}
if (!Array.isArray(entry.options)) {
return [];
}
return entry.options.flatMap((nested) => flattenConfigOptions(nested));
}
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
return null;
@ -2368,6 +2690,60 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
return JSON.parse(JSON.stringify(value)) as SessionModeState;
}
function availablePermissionReplies(options: PermissionOption[]): PermissionReply[] {
const replies = new Set<PermissionReply>();
for (const option of options) {
if (option.kind === "allow_once") {
replies.add("once");
} else if (option.kind === "allow_always") {
replies.add("always");
} else if (option.kind === "reject_once" || option.kind === "reject_always") {
replies.add("reject");
}
}
return [...replies];
}
function permissionReplyToResponse(
permissionId: string,
request: RequestPermissionRequest,
reply: PermissionReply,
): RequestPermissionResponse {
const preferredKinds: PermissionOptionKind[] =
reply === "once"
? ["allow_once"]
: reply === "always"
? ["allow_always", "allow_once"]
: ["reject_once", "reject_always"];
const selected = preferredKinds
.map((kind) => request.options.find((option) => option.kind === kind))
.find((option): option is PermissionOption => Boolean(option));
if (!selected) {
throw new UnsupportedPermissionReplyError(
permissionId,
reply,
availablePermissionReplies(request.options),
);
}
return {
outcome: {
outcome: "selected",
optionId: selected.optionId,
},
};
}
function cancelledPermissionResponse(): RequestPermissionResponse {
return {
outcome: {
outcome: "cancelled",
},
};
}
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
return (
isRecord(value) &&

View file

@ -4,6 +4,7 @@ export {
SandboxAgent,
SandboxAgentError,
Session,
UnsupportedPermissionReplyError,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
@ -28,6 +29,10 @@ export type {
SessionResumeOrCreateRequest,
SessionSendOptions,
SessionEventListener,
PermissionReply,
PermissionRequestListener,
SessionPermissionRequest,
SessionPermissionRequestOption,
} from "./client.ts";
export type { InspectorUrlOptions } from "./inspector.ts";

View file

@ -25,10 +25,12 @@ export function prepareMockAgentDataHome(dataHome: string): Record<string, strin
runtimeEnv.XDG_DATA_HOME = dataHome;
}
const nodeScript = String.raw`#!/usr/bin/env node
const nodeScript = String.raw`#!/usr/bin/env node
const { createInterface } = require("node:readline");
let nextSession = 0;
let nextPermission = 0;
const pendingPermissions = new Map();
function emit(value) {
process.stdout.write(JSON.stringify(value) + "\n");
@ -65,6 +67,38 @@ rl.on("line", (line) => {
const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
const method = hasMethod ? msg.method : undefined;
if (!hasMethod && hasId) {
const pending = pendingPermissions.get(String(msg.id));
if (pending) {
pendingPermissions.delete(String(msg.id));
const outcome = msg?.result?.outcome;
const optionId = outcome?.outcome === "selected" ? outcome.optionId : "cancelled";
const suffix = optionId === "reject-once" ? "rejected" : "approved";
emit({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: pending.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "mock permission " + suffix + ": " + optionId,
},
},
},
});
emit({
jsonrpc: "2.0",
id: pending.promptId,
result: {
stopReason: "end_turn",
},
});
}
return;
}
if (method === "session/prompt") {
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
const text = firstText(msg?.params?.prompt);
@ -82,6 +116,51 @@ rl.on("line", (line) => {
},
},
});
if (text.includes("permission")) {
nextPermission += 1;
const permissionId = "permission-" + nextPermission;
pendingPermissions.set(permissionId, {
promptId: msg.id,
sessionId,
});
emit({
jsonrpc: "2.0",
id: permissionId,
method: "session/request_permission",
params: {
sessionId,
toolCall: {
toolCallId: "tool-call-" + nextPermission,
title: "Write mock.txt",
kind: "edit",
status: "pending",
locations: [{ path: "/tmp/mock.txt" }],
rawInput: {
path: "/tmp/mock.txt",
content: "hello",
},
},
options: [
{
kind: "allow_once",
name: "Allow once",
optionId: "allow-once",
},
{
kind: "allow_always",
name: "Always allow",
optionId: "allow-always",
},
{
kind: "reject_once",
name: "Reject",
optionId: "reject-once",
},
],
},
});
}
}
if (!hasMethod || !hasId) {
@ -117,6 +196,10 @@ rl.on("line", (line) => {
}
if (method === "session/prompt") {
const text = firstText(msg?.params?.prompt);
if (text.includes("permission")) {
return;
}
emit({
jsonrpc: "2.0",
id: msg.id,

View file

@ -512,10 +512,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
const session = await sdk.createSession({ agent: "mock" });
await expect(session.send("session/cancel")).rejects.toThrow(
await expect(session.rawSend("session/cancel")).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
@ -625,6 +625,43 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("surfaces ACP permission requests and maps approve/reject replies", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
const permissionIds: string[] = [];
const permissionTexts: string[] = [];
const offPermissions = session.onPermissionRequest((request) => {
permissionIds.push(request.id);
const reply = permissionIds.length === 1 ? "reject" : "always";
void session.respondPermission(request.id, reply);
});
const offEvents = session.onEvent((event) => {
const text = (event.payload as any)?.params?.update?.content?.text;
if (typeof text === "string" && text.startsWith("mock permission ")) {
permissionTexts.push(text);
}
});
await session.prompt([{ type: "text", text: "trigger permission request one" }]);
await session.prompt([{ type: "text", text: "trigger permission request two" }]);
await waitFor(() => (permissionIds.length === 2 ? permissionIds : undefined));
await waitFor(() => (permissionTexts.length === 2 ? permissionTexts : undefined));
expect(permissionTexts[0]).toContain("rejected");
expect(permissionTexts[1]).toContain("approved");
offEvents();
offPermissions();
await sdk.dispose();
});
it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,