From 7e9517918898bb524e55d1e0fc42d7956d880e4d Mon Sep 17 00:00:00 2001 From: "mark.tkachenko" Date: Fri, 11 Sep 2026 13:49:04 +0200 Subject: [PATCH] feat: support v2 command permissions --- acp-model/api/acp-model.api | 36 +++++ .../model/v2/Permission.kt | 27 ++++ .../model/v2/RequestPermissionOutcomeTest.kt | 12 ++ .../model/v2/RequestPermissionRequestTest.kt | 136 ++++++++++++++++++ .../model/v2/RequestPermissionSubjectTest.kt | 110 ++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionRequestTest.kt create mode 100644 acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionSubjectTest.kt diff --git a/acp-model/api/acp-model.api b/acp-model/api/acp-model.api index 02981ff..9d074ca 100644 --- a/acp-model/api/acp-model.api +++ b/acp-model/api/acp-model.api @@ -12105,6 +12105,42 @@ public abstract class com/agentclientprotocol/model/v2/RequestPermissionSubject public static final field Companion Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Companion; } +public final class com/agentclientprotocol/model/v2/RequestPermissionSubject$Command : com/agentclientprotocol/model/v2/RequestPermissionSubject, com/agentclientprotocol/model/AcpWithMeta { + public static final field Companion Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command$Companion; + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3-6c_jIZA ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Lkotlinx/serialization/json/JsonElement; + public final fun copy-ARPg0Pc (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command; + public static synthetic fun copy-ARPg0Pc$default (Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;ILjava/lang/Object;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command; + public fun equals (Ljava/lang/Object;)Z + public final fun getCommand ()Ljava/lang/String; + public final fun getCwd ()Ljava/lang/String; + public final fun getTerminalId ()Ljava/lang/String; + public final fun getToolCallId-6c_jIZA ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonElement; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/agentclientprotocol/model/v2/RequestPermissionSubject$Command$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/agentclientprotocol/model/v2/RequestPermissionSubject$Command$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class com/agentclientprotocol/model/v2/RequestPermissionSubject$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/v2/Permission.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/v2/Permission.kt index 1b9d657..44cf10d 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/v2/Permission.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/v2/Permission.kt @@ -9,6 +9,7 @@ import com.agentclientprotocol.model.AcpWithMeta import com.agentclientprotocol.model.AcpWithSessionId import com.agentclientprotocol.model.PermissionOptionId import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.ToolCallId import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -47,6 +48,27 @@ public sealed class RequestPermissionSubject { } } + /** + * Permission to execute [command] in [cwd], which MUST be an absolute path on the agent's system. + * + * [toolCallId] and [terminalId] only associate the permission with displayed state and may be + * unavailable before execution starts. Omitted or `null` IDs mean no association was provided. + * Selecting an allow option authorizes the agent to execute the command; it does not ask the + * client to execute it. + */ + @Serializable + public data class Command( + val command: String, + val cwd: String, + val toolCallId: ToolCallId? = null, + val terminalId: String? = null, + override val _meta: JsonElement? = null, + ) : RequestPermissionSubject(), AcpWithMeta { + public companion object { + internal const val DISCRIMINATOR: String = "command" + } + } + /** * Custom or future subject. * @@ -62,10 +84,12 @@ internal object RequestPermissionSubjectSerializer : OpenTaggedUnionSerializer when (value) { is RequestPermissionSubject.ToolCall -> RequestPermissionSubject.ToolCall.DISCRIMINATOR + is RequestPermissionSubject.Command -> RequestPermissionSubject.Command.DISCRIMINATOR is RequestPermissionSubject.Unknown -> value.type } }, @@ -80,6 +104,9 @@ internal object RequestPermissionSubjectSerializer : OpenTaggedUnionSerializer(response.outcome) + assertEquals(json, ACPJson.encodeToString(serializer, response)) + assertFailsWith { response.outcome.toV1() } + } + // Strictness @Test diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionRequestTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionRequestTest.kt new file mode 100644 index 0000000..bef7a43 --- /dev/null +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionRequestTest.kt @@ -0,0 +1,136 @@ +@file:OptIn(UnstableApi::class) + +package com.agentclientprotocol.model.v2 + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.PermissionOptionId +import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.ToolCallId +import com.agentclientprotocol.rpc.ACPJson +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class RequestPermissionRequestTest { + private val method = AcpMethod.ClientMethods.V2.SessionRequestPermission + private val options = listOf(PermissionOption(PermissionOptionId("allow-once"), "Allow once", PermissionOptionKind.AllowOnce)) + + @Test + fun `minimal permission prompt needs no description or subject`() { + val json = """{"sessionId":"sess_1","title":"Continue?","options":[""" + + """{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}]}""" + val request = RequestPermissionRequest(SessionId("sess_1"), "Continue?", options) + + assertEquals(request, decode(json)) + assertEquals(json, encode(request)) + assertEquals( + request, + decode(json.dropLast(1) + """, "description":null,"subject":null}"""), + ) + } + + @Test + fun `command request uses the v2 wire shape and preserves metadata at each level`() { + val json = """{ + "sessionId":"sess_1", + "title":"Run the test suite?", + "description":"Allow the agent to run cargo test?", + "subject":{ + "type":"command", + "command":"cargo test", + "cwd":"/project", + "toolCallId":"call_001", + "terminalId":"term_001", + "_meta":{"scope":"subject"} + }, + "options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}], + "_meta":{"scope":"request"} + }""" + val request = decode(json) + + assertEquals("Run the test suite?", request.title) + assertEquals("Allow the agent to run cargo test?", request.description) + assertEquals(options, request.options) + assertEquals( + RequestPermissionSubject.Command( + command = "cargo test", + cwd = "/project", + toolCallId = ToolCallId("call_001"), + terminalId = "term_001", + _meta = buildJsonObject { put("scope", "subject") }, + ), + request.subject, + ) + assertEquals(buildJsonObject { put("scope", "request") }, request._meta) + assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request))) + } + + @Test + fun `prompt text stays separate from tool call upsert fields`() { + val json = """{ + "sessionId":"sess_1", + "title":"Approve the edit?", + "description":"Allow this change?", + "subject":{ + "type":"tool_call", + "toolCall":{"toolCallId":"call_001","title":"Editing configuration","content":null,"locations":[]} + }, + "options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}] + }""" + val request = decode(json) + val subject = assertIs(request.subject) + + assertEquals("Approve the edit?", request.title) + assertEquals("Allow this change?", request.description) + assertEquals(MaybeUndefined.Value("Editing configuration"), subject.toolCall.title) + assertEquals(MaybeUndefined.Null, subject.toolCall.content) + assertEquals(MaybeUndefined.Value(emptyList()), subject.toolCall.locations) + assertEquals(MaybeUndefined.Undefined, subject.toolCall.status) + assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request))) + } + + @Test + fun `session id title and options are required`() { + val valid = ACPJson.parseToJsonElement(encode(RequestPermissionRequest(SessionId("sess_1"), "Continue?", options))) + .jsonObject + + for (field in listOf("sessionId", "title", "options")) { + val missingField = JsonObject(valid.filterKeys { it != field }) + assertFailsWith(field) { decode(missingField.toString()) } + } + assertFailsWith { + decode( + """{"sessionId":"sess_1","toolCall":{"toolCallId":"call_001"},"options":[""" + + """{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}]}""" + ) + } + } + + @Test + fun `future subjects and option kinds survive request forwarding`() { + val json = """{ + "sessionId":"sess_1", + "title":"Approve network access?", + "subject":{"type":"_network","host":"example.com"}, + "options":[{"optionId":"scoped","name":"Allow for this host","kind":"_allow_host"}] + }""" + val request = decode(json) + + assertIs(request.subject) + assertEquals(PermissionOptionKind.Unknown("_allow_host"), request.options.single().kind) + assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request))) + } + + private fun decode(json: String): RequestPermissionRequest = + ACPJson.decodeFromString(method.requestSerializer, json) + + private fun encode(request: RequestPermissionRequest): String = + ACPJson.encodeToString(method.requestSerializer, request) +} diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionSubjectTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionSubjectTest.kt new file mode 100644 index 0000000..18fcf89 --- /dev/null +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/v2/RequestPermissionSubjectTest.kt @@ -0,0 +1,110 @@ +@file:OptIn(UnstableApi::class) + +package com.agentclientprotocol.model.v2 + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.model.ToolCallId +import com.agentclientprotocol.rpc.ACPJson +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class RequestPermissionSubjectTest { + @Test + fun `command requires no association with an existing tool call or terminal`() { + val json = """{"type":"command","command":"cargo test","cwd":"/home/user/project"}""" + val subject = RequestPermissionSubject.Command(command = "cargo test", cwd = "/home/user/project") + + assertEquals(subject, decode(json)) + assertEquals(json, encode(subject)) + } + + @Test + fun `command preserves optional associations and metadata`() { + val json = """{"type":"command","command":"cargo test","cwd":"/home/user/project",""" + + """"toolCallId":"call_001","terminalId":"term_001","_meta":{"source":"agent"}}""" + val subject = RequestPermissionSubject.Command( + command = "cargo test", + cwd = "/home/user/project", + toolCallId = ToolCallId("call_001"), + terminalId = "term_001", + _meta = buildJsonObject { put("source", "agent") }, + ) + + assertEquals(subject, decode(json)) + assertEquals(json, encode(subject)) + } + + @Test + fun `omitted and null optional command fields are equivalent`() { + assertEquals( + decode("""{"type":"command","command":"cargo test","cwd":"/project"}"""), + decode( + """{"type":"command","command":"cargo test","cwd":"/project",""" + + """"toolCallId":null,"terminalId":null,"_meta":null}""" + ), + ) + } + + @Test + fun `command paths are preserved independently of the receiving platform`() { + for (cwd in listOf("/project", "C:\\project", "C:/project", "\\\\server\\share\\project")) { + val subject = RequestPermissionSubject.Command(command = "gradlew test", cwd = cwd) + + assertEquals(subject, decode(encode(subject))) + } + } + + @Test + fun `tool call subject accepts an upsert containing only the id`() { + val json = """{"type":"tool_call","toolCall":{"toolCallId":"call_001"}}""" + val subject = RequestPermissionSubject.ToolCall(ToolCallUpdate(ToolCallId("call_001"))) + + assertEquals(subject, decode(json)) + assertEquals(json, encode(subject)) + } + + @Test + fun `known subjects with missing or null required fields fail instead of becoming Unknown`() { + val malformed = listOf( + """{"type":"command","cwd":"/project"}""", + """{"type":"command","command":"cargo test"}""", + """{"type":"command","command":null,"cwd":"/project"}""", + """{"type":"command","command":"cargo test","cwd":null}""", + """{"type":"tool_call"}""", + """{"type":"tool_call","toolCall":{}}""", + ) + + for (json in malformed) { + assertFailsWith(json) { decode(json) } + } + } + + @Test + fun `subject discriminator must be present and a string`() { + for (json in listOf("{}", """{"type":null}""", """{"type":42}""", """{"type":{}}""")) { + assertFailsWith(json) { decode(json) } + } + } + + @Test + fun `future and extension subjects preserve the full payload`() { + for (type in listOf("network", "_vendor_subject")) { + val json = """{"type":"$type","hosts":["example.com"],"policy":{"expires":null}}""" + val subject = assertIs(decode(json)) + + assertEquals(type, subject.type) + assertEquals(json, encode(subject)) + } + } + + private fun decode(json: String): RequestPermissionSubject = + ACPJson.decodeFromString(RequestPermissionSubject.serializer(), json) + + private fun encode(subject: RequestPermissionSubject): String = + ACPJson.encodeToString(RequestPermissionSubject.serializer(), subject) +}