Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/).

## [Unreleased]
### Added
- `:no-result` permission outcome — extensions can attach to sessions without actively answering permission requests by returning `{:kind :no-result}` from their `:on-permission-request` handler. On v3 protocol, the `handlePendingPermissionRequest` RPC is skipped; on v2, an error is propagated to the CLI (upstream PR #802).

## [0.1.32.0] - 2026-03-12
### Added (upstream sync)
Expand Down
3 changes: 3 additions & 0 deletions doc/reference/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,9 @@ fields like `:full-command-text`, `:commands`, and `:possible-paths`.

;; Deny after user interaction (optional feedback)
{:kind :denied-interactively-by-user :feedback "Not allowed"}

;; Extension declines to answer (another handler may respond)
{:kind :no-result}
```

#### `approve-all`
Expand Down
33 changes: 22 additions & 11 deletions src/github/copilot_sdk/client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@
(defn- handle-v3-permission-requested!
"Handle v3 permission.requested broadcast event.
Calls the session's permission handler and responds via the
session.permissions.handlePendingPermissionRequest RPC method."
session.permissions.handlePendingPermissionRequest RPC method.
When the handler returns :no-result, the RPC call is skipped
so the extension does not answer this permission request."
[client session-id event]
(let [data (:data event)
request-id (:request-id data)
Expand All @@ -257,13 +259,15 @@
(try
(let [perm-response (<! (session/handle-permission-request!
client session-id permission-request))
result (:result perm-response)
conn (:connection-io @(:state client))]
(when conn
(<! (proto/send-request conn "session.permissions.handlePendingPermissionRequest"
{:session-id session-id
:request-id request-id
:result result}))))
result (:result perm-response)]
;; :no-result — extension declines to answer; skip the RPC call
(when-not (= :no-result result)
(let [conn (:connection-io @(:state client))]
(when conn
(<! (proto/send-request conn "session.permissions.handlePendingPermissionRequest"
{:session-id session-id
:request-id request-id
:result result}))))))
(catch Exception e
(log/debug "v3 permission request error for " request-id ": " (ex-message e))
(try
Expand Down Expand Up @@ -495,9 +499,16 @@
(let [{:keys [session-id permission-request]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:result {:kind :denied-no-approval-rule-and-could-not-request-from-user}}
(let [result (<! (session/handle-permission-request! client session-id permission-request))]
(log/debug "Permission response for session " session-id ": " result)
{:result result})))
(let [perm-response (<! (session/handle-permission-request! client session-id permission-request))
result (:result perm-response)]
(if (= :no-result result)
;; no-result must propagate as an error on v2 protocol
;; so the CLI knows no answer was given (matches upstream -32603)
{:error {:code -32603
:message "Permission handler returned no-result on protocol v2"}}
(do
(log/debug "Permission response for session " session-id ": " result)
{:result perm-response})))))

;; User input request (PR #269)
"userInput.request"
Expand Down
17 changes: 16 additions & 1 deletion src/github/copilot_sdk/session.clj
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,13 @@
:mixed))

(defn handle-permission-request!
"Handle an incoming permission request. Returns a channel with the result."
"Handle an incoming permission request. Returns a channel with the result.
When the handler returns `{:kind :no-result}`, the result is
`{:result :no-result}` — callers must check for this sentinel:
- **v3 (broadcast path):** skip the `handlePendingPermissionRequest` RPC
entirely so the extension does not answer this permission request.
- **v2 (request-handler path):** propagate as a JSON-RPC internal error
(code -32603) so the CLI knows the request was not handled."
[client session-id request]
(async/thread-call
(fn []
Expand All @@ -210,9 +216,18 @@
(<!! result)
result)]
(cond
;; no-result: extension doesn't answer this permission request
(and (map? result) (= :no-result (:kind result)))
{:result :no-result}

(and (map? result) (contains? result :kind))
{:result result}

;; Wrapped form: {:result {:kind ...}}
(and (map? result) (contains? result :result)
(map? (:result result)) (= :no-result (:kind (:result result))))
{:result :no-result}

(and (map? result) (contains? result :result)
(map? (:result result)) (contains? (:result result) :kind))
result
Expand Down
3 changes: 2 additions & 1 deletion src/github/copilot_sdk/specs.clj
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,8 @@
#{:approved
:denied-by-rules
:denied-no-approval-rule-and-could-not-request-from-user
:denied-interactively-by-user})
:denied-interactively-by-user
:no-result})

(s/def ::permission-result
(s/keys :req-un [::permission-result-kind]
Expand Down
93 changes: 93 additions & 0 deletions test/github/copilot_sdk/integration_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,99 @@
(is (= :approved (get-in approved [:result :result :kind])))
(is (= :denied-by-rules (get-in denied [:result :result :kind]))))))

(deftest test-permission-no-result-v2
(testing "no-result permission handler returns error on v2 protocol"
(let [session (sdk/create-session *test-client*
{:on-permission-request
(fn [_request _ctx]
{:kind :no-result})})
session-id (sdk/session-id session)
handler (get-in @(:state *test-client*) [:connection :request-handler])
response (<!! (handler "permission.request"
{:session-id session-id
:permission-request {:permission-kind "shell"
:full-command-text "echo test"}}))]
;; v2: no-result should propagate as a JSON-RPC internal error
(is (= -32603 (get-in response [:error :code]))
"no-result on v2 should produce a -32603 internal error")))

(testing "wrapped no-result permission handler returns error on v2 protocol"
(let [session (sdk/create-session *test-client*
{:on-permission-request
(fn [_request _ctx]
{:result {:kind :no-result}})})
session-id (sdk/session-id session)
handler (get-in @(:state *test-client*) [:connection :request-handler])
response (<!! (handler "permission.request"
{:session-id session-id
:permission-request {:permission-kind "shell"
:full-command-text "echo test"}}))]
(is (= -32603 (get-in response [:error :code]))
"wrapped no-result on v2 should also produce a -32603 error"))))

(deftest test-permission-no-result-v3
(testing "v3 no-result skips handlePendingPermissionRequest RPC"
(let [requests (atom [])
_ (mock/set-request-hook! *mock-server*
(fn [method params]
(swap! requests conj {:method method :params params})))
session (sdk/create-session *test-client*
{:on-permission-request
(fn [_request _ctx]
{:kind :no-result})})
session-id (sdk/session-id session)]
;; Force protocol v3 so the broadcast path is active
(swap! (:state *test-client*) assoc :negotiated-protocol-version 3)
;; Reset captured requests after session creation
(reset! requests [])
;; Inject a v3 permission.requested broadcast event
(mock/send-session-event! *mock-server* session-id
"permission.requested"
{:requestId "perm-req-1"
:permissionRequest {:permissionKind "shell"
:fullCommandText "echo test"}})
;; Allow async handler to process
(Thread/sleep 500)
;; The handler returned no-result — no handlePendingPermissionRequest RPC
(is (empty? (filter #(= "session.permissions.handlePendingPermissionRequest"
(:method %))
@requests))
"no-result should skip the handlePendingPermissionRequest RPC"))))

(deftest test-permission-approved-v3
(testing "v3 approved handler sends handlePendingPermissionRequest RPC"
(let [requests (atom [])
rpc-latch (java.util.concurrent.CountDownLatch. 1)
_ (mock/set-request-hook! *mock-server*
(fn [method params]
(swap! requests conj {:method method :params params})
(when (= "session.permissions.handlePendingPermissionRequest" method)
(.countDown rpc-latch))))
session (sdk/create-session *test-client*
{:on-permission-request sdk/approve-all})
session-id (sdk/session-id session)]
;; Force protocol v3
(swap! (:state *test-client*) assoc :negotiated-protocol-version 3)
;; Reset captured requests after session creation
(reset! requests [])
;; Inject a v3 permission.requested broadcast event
(mock/send-session-event! *mock-server* session-id
"permission.requested"
{:requestId "perm-req-2"
:permissionRequest {:permissionKind "shell"
:fullCommandText "echo test"}})
;; Wait for the RPC to arrive (up to 5 seconds)
(.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)
;; The handler approved — should send handlePendingPermissionRequest RPC
(let [perm-rpcs (filter #(= "session.permissions.handlePendingPermissionRequest"
(:method %))
@requests)]
(is (= 1 (count perm-rpcs))
"approved result should send handlePendingPermissionRequest RPC")
(when (seq perm-rpcs)
(is (= "perm-req-2" (:requestId (:params (first perm-rpcs))))
"RPC should include the correct request-id"))))))

;; -----------------------------------------------------------------------------
;; Last Session ID Tests
;; -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions test/github/copilot_sdk/mock_server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@
"session.model.getCurrent" (handle-session-model-get-current server params)
"session.model.switchTo" (handle-session-model-switch-to server params)
"session.log" (handle-session-log server params)
"session.permissions.handlePendingPermissionRequest" {:ok true}
(throw (ex-info "Method not found" {:code -32601 :method method})))]
{:jsonrpc "2.0"
:id (:id msg)
Expand Down
Loading