mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
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:
parent
5d65013aa5
commit
76586f409f
35 changed files with 1786 additions and 472 deletions
|
|
@ -378,31 +378,39 @@ class StreamableHttpAcpTransport {
|
|||
});
|
||||
|
||||
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
this.postedOnce = true;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
|
||||
this.ensureSseLoop();
|
||||
void this.postMessage(url, headers, message);
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise<void> {
|
||||
try {
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
} else {
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain response body so the underlying connection is released back to
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// may stall subsequent requests to the same origin.
|
||||
await response.text().catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("ACP write error:", error);
|
||||
this.failReadable(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,4 +140,54 @@ describe("AcpHttpClient integration", () => {
|
|||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("answers session/request_permission while session/prompt is still in flight", async () => {
|
||||
const permissionRequests: Array<{ sessionId: string; title?: string | null }> = [];
|
||||
const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`;
|
||||
|
||||
const client = new AcpHttpClient({
|
||||
baseUrl,
|
||||
token,
|
||||
transport: {
|
||||
path: `/v1/acp/${encodeURIComponent(serverId)}`,
|
||||
bootstrapQuery: { agent: "mock" },
|
||||
},
|
||||
client: {
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push({
|
||||
sessionId: request.sessionId,
|
||||
title: request.toolCall.title,
|
||||
});
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: "reject-once",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await client.initialize();
|
||||
|
||||
const session = await client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const prompt = await client.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "please trigger permission" }],
|
||||
});
|
||||
|
||||
expect(prompt.stopReason).toBe("end_turn");
|
||||
expect(permissionRequests).toEqual([
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
title: "Write mock.txt",
|
||||
},
|
||||
]);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue