mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 17:01:06 +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
|
|
@ -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