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>
This commit is contained in:
Nathan Flurry 2026-03-10 21:52:31 -07:00
parent e8ffd78ac0
commit 6f66f92e72
4 changed files with 16 additions and 9 deletions

View file

@ -34,6 +34,7 @@ console.log(url);
- Event JSON inspector - Event JSON inspector
- Prompt testing - Prompt testing
- Request/response debugging - Request/response debugging
- Interactive permission prompts (approve, always-allow, or reject tool-use requests)
- Process management (create, stop, kill, delete, view logs) - Process management (create, stop, kill, delete, view logs)
- Interactive PTY terminal for tty processes - Interactive PTY terminal for tty processes
- One-shot command execution - One-shot command execution

View file

@ -870,7 +870,7 @@ export default function App() {
// Apply mode if selected // Apply mode if selected
if (!skipPostCreateConfig && config.agentMode) { if (!skipPostCreateConfig && config.agentMode) {
try { try {
await session.send("session/set_mode", { modeId: config.agentMode }); await session.rawSend("session/set_mode", { modeId: config.agentMode });
} catch { } catch {
// Mode application is best-effort // Mode application is best-effort
} }
@ -886,7 +886,7 @@ export default function App() {
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string" (opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
); );
if (modelOption && config.model !== modelOption.currentValue) { if (modelOption && config.model !== modelOption.currentValue) {
await session.send("session/set_config_option", { await session.rawSend("session/set_config_option", {
optionId: modelOption.id, optionId: modelOption.id,
value: config.model, value: config.model,
}); });
@ -1241,7 +1241,6 @@ export default function App() {
})); }));
const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request"; const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request";
const resolved = resolvedPermissions.get(permissionId); const resolved = resolvedPermissions.get(permissionId);
const isPending = pendingPermissionIds.has(permissionId);
entries.push({ entries.push({
id: event.id, id: event.id,
eventId: event.id, eventId: event.id,
@ -1252,7 +1251,7 @@ export default function App() {
title, title,
description: params?.toolCall?.description, description: params?.toolCall?.description,
options, options,
resolved: resolved != null || !isPending, resolved: resolved != null || sdkPermissionId == null,
selectedOptionId: resolved, selectedOptionId: resolved,
}, },
}); });
@ -1288,7 +1287,7 @@ export default function App() {
} }
return entries; return entries;
}, [events, pendingPermissionIds, resolvedPermissions]); }, [events, resolvedPermissions]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View file

@ -1262,7 +1262,7 @@ export class SandboxAgent {
} }
private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise<SessionRecord> { private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise<SessionRecord> {
if (snapshot.configOptions !== undefined && snapshot.configOptions.length > 0) { if (snapshot.configOptions !== undefined) {
return snapshot; return snapshot;
} }
@ -1440,7 +1440,14 @@ export class SandboxAgent {
throw new Error(`permission '${permissionId}' not found`); throw new Error(`permission '${permissionId}' not found`);
} }
const response = permissionReplyToResponse(permissionId, pending.request, reply); 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); this.resolvePendingPermission(permissionId, response);
} }
@ -2704,7 +2711,7 @@ function permissionReplyToResponse(
): RequestPermissionResponse { ): RequestPermissionResponse {
const preferredKinds: PermissionOptionKind[] = const preferredKinds: PermissionOptionKind[] =
reply === "once" reply === "once"
? ["allow_once", "allow_always"] ? ["allow_once"]
: reply === "always" : reply === "always"
? ["allow_always", "allow_once"] ? ["allow_always", "allow_once"]
: ["reject_once", "reject_always"]; : ["reject_once", "reject_always"];

View file

@ -512,7 +512,7 @@ describe("Integration: TypeScript SDK flat session API", () => {
const session = await sdk.createSession({ agent: "mock" }); 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.", "Use destroySession(sessionId) instead.",
); );
await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow( await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(