on main help sign in
fix(permissions): make permission resolution idempotent across clients

When multiple clients are viewing the same session, both can answer the same permission prompt. Without idempotency, the second answer could publish a duplicate notification, block a goroutine, or corrupt session-wide approval state. Permission grants and denials now resolve a request at most once and report back whether the call actually resolved it. A losing persistent grant no longer leaves behind a stale session-wide approval. Also fixes a long standing mix-up where one-time and persistent grants were swapped on the wire when running with a remote server. Co-Authored-By: Charm Crush <crush@charm.land>

commit86568da1153b165d47a79d8a14f1addec5a41289 authorChristian Rocha <christian@rocha.is> dateTue 19 May 2026, 22:39 (5 months ago) parent1ce40df
13 files changed+504−88
internal/agent/tools/bash_test.go+10−6 internal/agent/tools/multiedit_test.go+5−3 internal/agent/tools/view_test.go+5−3 internal/backend/permission.go+9−8 internal/client/proto.go+13−5 internal/permission/permission.go+62−43 internal/permission/permission_test.go+271 internal/proto/proto.go+9 internal/server/proto.go+4−3 internal/workspace/app_workspace.go+6−6 internal/workspace/client_workspace.go+11−8 internal/workspace/client_workspace_test.go+88 internal/workspace/workspace.go+11−3
internal/agent/tools/bash_test.go+10−6
@@ -21,11 +21,13 @@ func (m *mockBashPermissionService) Request(ctx context.Context, req permission.
2121 return true, nil
2222}
2323
24−func (m *mockBashPermissionService) Grant(req permission.PermissionRequest) {}
24+func (m *mockBashPermissionService) Grant(req permission.PermissionRequest) bool { return true }
2525
26−func (m *mockBashPermissionService) Deny(req permission.PermissionRequest) {}
26+func (m *mockBashPermissionService) Deny(req permission.PermissionRequest) bool { return true }
2727
28−func (m *mockBashPermissionService) GrantPersistent(req permission.PermissionRequest) {}
28+func (m *mockBashPermissionService) GrantPersistent(req permission.PermissionRequest) bool {
29+ return true
30+}
2931
3032func (m *mockBashPermissionService) AutoApproveSession(sessionID string) {}
3133
@@ -90,11 +92,13 @@ func (m *recordingPermissionService) Request(ctx context.Context, req permission
9092 return m.allow, nil
9193}
9294
93−func (m *recordingPermissionService) Grant(req permission.PermissionRequest) {}
95+func (m *recordingPermissionService) Grant(req permission.PermissionRequest) bool { return true }
9496
95−func (m *recordingPermissionService) Deny(req permission.PermissionRequest) {}
97+func (m *recordingPermissionService) Deny(req permission.PermissionRequest) bool { return true }
9698
97−func (m *recordingPermissionService) GrantPersistent(req permission.PermissionRequest) {}
99+func (m *recordingPermissionService) GrantPersistent(req permission.PermissionRequest) bool {
100+ return true
101+}
98102
99103func (m *recordingPermissionService) AutoApproveSession(sessionID string) {}
100104
internal/agent/tools/multiedit_test.go+5−3
@@ -20,11 +20,13 @@ func (m *mockPermissionService) Request(ctx context.Context, req permission.Crea
2020 return true, nil
2121}
2222
23−func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
23+func (m *mockPermissionService) Grant(req permission.PermissionRequest) bool { return true }
2424
25−func (m *mockPermissionService) Deny(req permission.PermissionRequest) {}
25+func (m *mockPermissionService) Deny(req permission.PermissionRequest) bool { return true }
2626
27−func (m *mockPermissionService) GrantPersistent(req permission.PermissionRequest) {}
27+func (m *mockPermissionService) GrantPersistent(req permission.PermissionRequest) bool {
28+ return true
29+}
2830
2931func (m *mockPermissionService) AutoApproveSession(sessionID string) {}
3032
internal/agent/tools/view_test.go+5−3
@@ -216,11 +216,13 @@ func (m *mockViewPermissionService) Request(ctx context.Context, req permission.
216216 return true, nil
217217}
218218
219−func (m *mockViewPermissionService) Grant(req permission.PermissionRequest) {}
219+func (m *mockViewPermissionService) Grant(req permission.PermissionRequest) bool { return true }
220220
221−func (m *mockViewPermissionService) Deny(req permission.PermissionRequest) {}
221+func (m *mockViewPermissionService) Deny(req permission.PermissionRequest) bool { return true }
222222
223−func (m *mockViewPermissionService) GrantPersistent(req permission.PermissionRequest) {}
223+func (m *mockViewPermissionService) GrantPersistent(req permission.PermissionRequest) bool {
224+ return true
225+}
224226
225227func (m *mockViewPermissionService) AutoApproveSession(sessionID string) {}
226228
internal/backend/permission.go+9−8
@@ -6,11 +6,13 @@ import (
66)
77
88// GrantPermission grants, denies, or persistently grants a permission
9−// request.
10−func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error {
9+// request. The returned bool reports whether this call resolved the
10+// pending request (true) or found it already resolved by a previous
11+// caller (false). A false return is not an error.
12+func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) (bool, error) {
1113 ws, err := b.GetWorkspace(workspaceID)
1214 if err != nil {
13− return err
15+ return false, err
1416 }
1517
1618 perm := permission.PermissionRequest{
@@ -26,15 +28,14 @@ func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant)
2628
2729 switch req.Action {
2830 case proto.PermissionAllow:
29− ws.Permissions.Grant(perm)
31+ return ws.Permissions.Grant(perm), nil
3032 case proto.PermissionAllowForSession:
31− ws.Permissions.GrantPersistent(perm)
33+ return ws.Permissions.GrantPersistent(perm), nil
3234 case proto.PermissionDeny:
33− ws.Permissions.Deny(perm)
35+ return ws.Permissions.Deny(perm), nil
3436 default:
35− return ErrInvalidPermissionAction
37+ return false, ErrInvalidPermissionAction
3638 }
37− return nil
3839}
3940
4041// SetPermissionsSkip sets whether permission prompts are skipped.
internal/client/proto.go+13−5
@@ -490,17 +490,25 @@ func (c *Client) ListSessions(ctx context.Context, id string) ([]proto.Session,
490490 return sessions, nil
491491}
492492
493−// GrantPermission grants a permission on a workspace.
494−func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
493+// GrantPermission grants a permission on a workspace. The returned
494+// bool reports whether this call resolved the pending request (true)
495+// or found it already resolved by a previous caller (false). A false
496+// value is not an error — it just means another subscriber resolved
497+// the same request first.
498+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) (bool, error) {
495499 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
496500 if err != nil {
497− return fmt.Errorf("failed to grant permission: %w", err)
501+ return false, fmt.Errorf("failed to grant permission: %w", err)
498502 }
499503 defer rsp.Body.Close()
500504 if rsp.StatusCode != http.StatusOK {
501− return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
505+ return false, fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
502506 }
503− return nil
507+ var resp proto.PermissionGrantResponse
508+ if err := json.NewDecoder(rsp.Body).Decode(&resp); err != nil {
509+ return false, fmt.Errorf("failed to decode grant permission response: %w", err)
510+ }
511+ return resp.Resolved, nil
504512}
505513
506514// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
internal/permission/permission.go+62−43
@@ -63,9 +63,19 @@ type PermissionRequest struct {
6363
6464type Service interface {
6565 pubsub.Subscriber[PermissionRequest]
66− GrantPersistent(permission PermissionRequest)
67− Grant(permission PermissionRequest)
68− Deny(permission PermissionRequest)
66+ // GrantPersistent grants a permission request and remembers the grant
67+ // for the session. It returns true if this call actually resolved the
68+ // pending request; false if the request had already been resolved
69+ // (e.g., by another concurrent caller) or is unknown.
70+ GrantPersistent(permission PermissionRequest) bool
71+ // Grant grants a permission request. It returns true if this call
72+ // actually resolved the pending request; false if the request had
73+ // already been resolved or is unknown.
74+ Grant(permission PermissionRequest) bool
75+ // Deny denies a permission request. It returns true if this call
76+ // actually resolved the pending request; false if the request had
77+ // already been resolved or is unknown.
78+ Deny(permission PermissionRequest) bool
6979 Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
7080 AutoApproveSession(sessionID string)
7181 SetSkipRequests(skip bool)
@@ -99,63 +109,72 @@ type permissionService struct {
99109 activeRequestMu sync.Mutex
100110}
101111
102−func (s *permissionService) GrantPersistent(permission PermissionRequest) {
103− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
104− ToolCallID: permission.ToolCallID,
105− Granted: true,
106− })
107− respCh, ok := s.pendingRequests.Get(permission.ID)
108− if ok {
109− respCh <- true
112+// resolve atomically removes the pending request entry for the given
113+// permission and, if it was still pending, publishes exactly one
114+// PermissionNotification and forwards the outcome to the waiter on
115+// respCh. It returns true if this call resolved the request, false if
116+// it had already been resolved (e.g., by another concurrent caller) or
117+// the request ID is unknown.
118+//
119+// If onResolve is non-nil it runs after the pending entry has been
120+// taken but before the notification is published or the waiter is
121+// unblocked. This lets GrantPersistent record the session permission
122+// only when it actually wins the race, so a losing GrantPersistent
123+// that lost to a Deny does not leak an auto-approve entry.
124+//
125+// All three public resolution methods (Grant, GrantPersistent, Deny)
126+// route through this helper so multi-subscriber UIs can race safely:
127+// the first caller wins, the rest become no-ops.
128+func (s *permissionService) resolve(permission PermissionRequest, granted, denied bool, onResolve func()) bool {
129+ respCh, ok := s.pendingRequests.Take(permission.ID)
130+ if !ok {
131+ return false
110132 }
111133
112− s.sessionPermissions.Set(PermissionKey{
113− SessionID: permission.SessionID,
114− ToolName: permission.ToolName,
115− Action: permission.Action,
116− Path: permission.Path,
117− }, true)
118−
119− s.activeRequestMu.Lock()
120− if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121− s.activeRequest = nil
134+ if onResolve != nil {
135+ onResolve()
122136 }
123− s.activeRequestMu.Unlock()
124−}
125137
126−func (s *permissionService) Grant(permission PermissionRequest) {
127138 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
128139 ToolCallID: permission.ToolCallID,
129− Granted: true,
140+ Granted: granted,
141+ Denied: denied,
130142 })
131− respCh, ok := s.pendingRequests.Get(permission.ID)
132− if ok {
133− respCh <- true
134− }
143+
144+ // respCh is buffered (cap 1) and only ever has at most one sender
145+ // per request because Take removes the entry under the map lock,
146+ // so this send never blocks.
147+ respCh <- granted
135148
136149 s.activeRequestMu.Lock()
137150 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
138151 s.activeRequest = nil
139152 }
140153 s.activeRequestMu.Unlock()
154+ return true
141155}
142156
143−func (s *permissionService) Deny(permission PermissionRequest) {
144− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
145− ToolCallID: permission.ToolCallID,
146− Granted: false,
147− Denied: true,
157+func (s *permissionService) GrantPersistent(permission PermissionRequest) bool {
158+ // Record the persistent grant only if this call wins the
159+ // pending-request race. Otherwise a losing GrantPersistent that
160+ // lost to a Deny would still leave an auto-approve entry behind,
161+ // silently flipping later denied calls to allowed.
162+ return s.resolve(permission, true, false, func() {
163+ s.sessionPermissions.Set(PermissionKey{
164+ SessionID: permission.SessionID,
165+ ToolName: permission.ToolName,
166+ Action: permission.Action,
167+ Path: permission.Path,
168+ }, true)
148169 })
149− respCh, ok := s.pendingRequests.Get(permission.ID)
150− if ok {
151− respCh <- false
152− }
170+}
153171
154− s.activeRequestMu.Lock()
155− if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
156− s.activeRequest = nil
157− }
158− s.activeRequestMu.Unlock()
172+func (s *permissionService) Grant(permission PermissionRequest) bool {
173+ return s.resolve(permission, true, false, nil)
174+}
175+
176+func (s *permissionService) Deny(permission PermissionRequest) bool {
177+ return s.resolve(permission, false, true, nil)
159178}
160179
161180func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
internal/permission/permission_test.go+271
@@ -2,7 +2,9 @@ package permission
22
33import (
44 "sync"
5+ "sync/atomic"
56 "testing"
7+ "time"
68
79 "github.com/stretchr/testify/assert"
810 "github.com/stretchr/testify/require"
@@ -327,3 +329,272 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
327329 assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
328330 })
329331}
332+
333+// TestPermissionService_ResolveIdempotency covers the multi-subscriber
334+// resolve guarantees added for client/server mode: exactly one
335+// notification per resolution, racing callers see "already resolved",
336+// and stray Grant/Deny calls for unknown IDs are safe no-ops.
337+func TestPermissionService_ResolveIdempotency(t *testing.T) {
338+ t.Parallel()
339+
340+ t.Run("concurrent grants resolve exactly once", func(t *testing.T) {
341+ t.Parallel()
342+ service := NewPermissionService("/tmp", false, nil)
343+
344+ events := service.Subscribe(t.Context())
345+ notifications := service.SubscribeNotifications(t.Context())
346+
347+ req := CreatePermissionRequest{
348+ SessionID: "race-session",
349+ ToolCallID: "race-call",
350+ ToolName: "tool",
351+ Action: "act",
352+ Path: "/tmp/race",
353+ }
354+
355+ var (
356+ wg sync.WaitGroup
357+ granted bool
358+ requestErr error
359+ )
360+ wg.Go(func() {
361+ granted, requestErr = service.Request(t.Context(), req)
362+ })
363+
364+ // Wait for the request to be published so we have a real
365+ // PermissionRequest (with its server-side ID) to race on.
366+ var pending PermissionRequest
367+ select {
368+ case ev := <-events:
369+ pending = ev.Payload
370+ case <-time.After(2 * time.Second):
371+ t.Fatal("permission request was never published")
372+ }
373+
374+ // Drain the initial "request opened" notification (Granted ==
375+ // false && Denied == false) so the next read is the resolution
376+ // itself.
377+ select {
378+ case ev := <-notifications:
379+ require.False(t, ev.Payload.Granted, "initial notification must not be granted")
380+ require.False(t, ev.Payload.Denied, "initial notification must not be denied")
381+ case <-time.After(2 * time.Second):
382+ t.Fatal("initial notification was never published")
383+ }
384+
385+ // Race two grants from two goroutines.
386+ var (
387+ resolvedCount atomic.Int32
388+ start = make(chan struct{})
389+ racers sync.WaitGroup
390+ )
391+ for range 2 {
392+ racers.Go(func() {
393+ <-start
394+ if service.Grant(pending) {
395+ resolvedCount.Add(1)
396+ }
397+ })
398+ }
399+ close(start)
400+ racers.Wait()
401+
402+ // Original Request must return granted exactly once.
403+ wg.Wait()
404+ require.NoError(t, requestErr)
405+ assert.True(t, granted, "request should observe its grant")
406+
407+ // Exactly one of the two grants resolved the request.
408+ assert.Equal(t, int32(1), resolvedCount.Load(),
409+ "exactly one Grant should report it resolved the request")
410+
411+ // Exactly one resolution notification, and no further ones.
412+ select {
413+ case ev := <-notifications:
414+ assert.True(t, ev.Payload.Granted, "resolution notification should be granted")
415+ assert.Equal(t, "race-call", ev.Payload.ToolCallID)
416+ case <-time.After(2 * time.Second):
417+ t.Fatal("resolution notification was never published")
418+ }
419+ select {
420+ case ev := <-notifications:
421+ t.Fatalf("unexpected duplicate notification: %+v", ev.Payload)
422+ case <-time.After(50 * time.Millisecond):
423+ // good: no duplicate.
424+ }
425+
426+ // pendingRequests must be empty: no goroutine is left blocked
427+ // on a send, and a future Grant for the same ID is a no-op.
428+ ps := service.(*permissionService)
429+ assert.Equal(t, 0, ps.pendingRequests.Len(),
430+ "pendingRequests must be empty after resolution")
431+
432+ assert.False(t, service.Grant(pending),
433+ "a third Grant should report already-resolved")
434+ })
435+
436+ t.Run("grant after deny is a no-op", func(t *testing.T) {
437+ t.Parallel()
438+ service := NewPermissionService("/tmp", false, nil)
439+
440+ events := service.Subscribe(t.Context())
441+ notifications := service.SubscribeNotifications(t.Context())
442+
443+ req := CreatePermissionRequest{
444+ SessionID: "deny-first",
445+ ToolCallID: "df-call",
446+ ToolName: "tool",
447+ Action: "act",
448+ Path: "/tmp/df",
449+ }
450+
451+ var (
452+ wg sync.WaitGroup
453+ granted bool
454+ requestErr error
455+ )
456+ wg.Go(func() {
457+ granted, requestErr = service.Request(t.Context(), req)
458+ })
459+
460+ var pending PermissionRequest
461+ select {
462+ case ev := <-events:
463+ pending = ev.Payload
464+ case <-time.After(2 * time.Second):
465+ t.Fatal("permission request was never published")
466+ }
467+
468+ // Drain the initial neither-granted-nor-denied notification.
469+ <-notifications
470+
471+ assert.True(t, service.Deny(pending), "Deny should resolve the request")
472+ wg.Wait()
473+ require.NoError(t, requestErr)
474+ assert.False(t, granted, "request should observe denial")
475+
476+ // A follow-up Grant must be a no-op and must not flip the
477+ // outcome or publish anything new.
478+ assert.False(t, service.Grant(pending),
479+ "Grant after Deny should report already-resolved")
480+
481+ select {
482+ case ev := <-notifications:
483+ // The first resolution notification (denial) is expected;
484+ // anything after that is a bug.
485+ require.True(t, ev.Payload.Denied,
486+ "the only post-initial notification must be the denial")
487+ case <-time.After(2 * time.Second):
488+ t.Fatal("denial notification was never published")
489+ }
490+ select {
491+ case ev := <-notifications:
492+ t.Fatalf("Grant after Deny must not publish: %+v", ev.Payload)
493+ case <-time.After(50 * time.Millisecond):
494+ // good.
495+ }
496+ })
497+
498+ t.Run("losing GrantPersistent does not record session permission", func(t *testing.T) {
499+ t.Parallel()
500+ service := NewPermissionService("/tmp", false, nil)
501+
502+ events := service.Subscribe(t.Context())
503+ notifications := service.SubscribeNotifications(t.Context())
504+
505+ req := CreatePermissionRequest{
506+ SessionID: "race-persist",
507+ ToolCallID: "rp-call",
508+ ToolName: "tool",
509+ Action: "act",
510+ Path: "/tmp/rp",
511+ }
512+
513+ var (
514+ wg sync.WaitGroup
515+ granted bool
516+ requestErr error
517+ )
518+ wg.Go(func() {
519+ granted, requestErr = service.Request(t.Context(), req)
520+ })
521+
522+ // Wait for the request to be published so we have the real
523+ // pending PermissionRequest to race on.
524+ var pending PermissionRequest
525+ select {
526+ case ev := <-events:
527+ pending = ev.Payload
528+ case <-time.After(2 * time.Second):
529+ t.Fatal("permission request was never published")
530+ }
531+
532+ // Drain the initial neither-granted-nor-denied notification.
533+ <-notifications
534+
535+ // Deny wins, then a competing GrantPersistent loses.
536+ assert.True(t, service.Deny(pending), "Deny should resolve the request")
537+ assert.False(t, service.GrantPersistent(pending),
538+ "GrantPersistent after Deny should report already-resolved")
539+
540+ wg.Wait()
541+ require.NoError(t, requestErr)
542+ assert.False(t, granted, "request should observe denial")
543+
544+ // The losing GrantPersistent must not have inserted an
545+ // auto-approve entry. Issue a matching follow-up request and
546+ // confirm the service still publishes a pending request (i.e.
547+ // not auto-approved). We then Deny it to drain the goroutine.
548+ var (
549+ wg2 sync.WaitGroup
550+ granted2 bool
551+ requestErr2 error
552+ )
553+ wg2.Go(func() {
554+ granted2, requestErr2 = service.Request(t.Context(), req)
555+ })
556+
557+ select {
558+ case ev := <-events:
559+ assert.Equal(t, pending.SessionID, ev.Payload.SessionID)
560+ service.Deny(ev.Payload)
561+ case <-time.After(2 * time.Second):
562+ t.Fatal("follow-up request was auto-approved; persistent grant leaked")
563+ }
564+
565+ wg2.Wait()
566+ require.NoError(t, requestErr2)
567+ assert.False(t, granted2, "follow-up request should be denied, not auto-approved")
568+ })
569+
570+ t.Run("grant for unknown id is a safe no-op", func(t *testing.T) {
571+ t.Parallel()
572+ service := NewPermissionService("/tmp", false, nil)
573+
574+ notifications := service.SubscribeNotifications(t.Context())
575+
576+ bogus := PermissionRequest{
577+ ID: "does-not-exist",
578+ ToolCallID: "ghost",
579+ ToolName: "tool",
580+ Action: "act",
581+ Path: "/tmp/ghost",
582+ }
583+
584+ assert.NotPanics(t, func() {
585+ assert.False(t, service.Grant(bogus),
586+ "Grant for unknown ID should report already-resolved")
587+ assert.False(t, service.GrantPersistent(bogus),
588+ "GrantPersistent for unknown ID should report already-resolved")
589+ assert.False(t, service.Deny(bogus),
590+ "Deny for unknown ID should report already-resolved")
591+ })
592+
593+ select {
594+ case ev := <-notifications:
595+ t.Fatalf("unknown-ID resolution must not publish: %+v", ev.Payload)
596+ case <-time.After(50 * time.Millisecond):
597+ // good: no notification.
598+ }
599+ })
600+}
internal/proto/proto.go+9
@@ -86,6 +86,15 @@ type PermissionGrant struct {
8686 Action PermissionAction `json:"action"`
8787}
8888
89+// PermissionGrantResponse is the server's response to a permission
90+// grant call. Resolved is true when this call resolved the pending
91+// request, and false when the request had already been resolved by a
92+// previous caller (e.g., another client in a multi-subscriber UI). A
93+// false value is not an error.
94+type PermissionGrantResponse struct {
95+ Resolved bool `json:"resolved"`
96+}
97+
8998// PermissionSkipRequest represents a request to skip permission prompts.
9099type PermissionSkipRequest struct {
91100 Skip bool `json:"skip"`
internal/server/proto.go+4−3
@@ -898,7 +898,7 @@ func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseW
898898// @Accept json
899899// @Param id path string true "Workspace ID"
900900// @Param request body proto.PermissionGrant true "Permission grant"
901−// @Success 200
901+// @Success 200 {object} proto.PermissionGrantResponse
902902// @Failure 400 {object} proto.Error
903903// @Failure 404 {object} proto.Error
904904// @Failure 500 {object} proto.Error
@@ -913,11 +913,12 @@ func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter
913913 return
914914 }
915915
916− if err := c.backend.GrantPermission(id, req); err != nil {
916+ resolved, err := c.backend.GrantPermission(id, req)
917+ if err != nil {
917918 c.handleError(w, r, err)
918919 return
919920 }
920− w.WriteHeader(http.StatusOK)
921+ jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
921922}
922923
923924// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
internal/workspace/app_workspace.go+6−6
@@ -173,16 +173,16 @@ func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedMo
173173
174174// -- Permissions --
175175
176−func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
177− w.app.Permissions.Grant(perm)
176+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) bool {
177+ return w.app.Permissions.Grant(perm)
178178}
179179
180−func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
181− w.app.Permissions.GrantPersistent(perm)
180+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) bool {
181+ return w.app.Permissions.GrantPersistent(perm)
182182}
183183
184−func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
185− w.app.Permissions.Deny(perm)
184+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) bool {
185+ return w.app.Permissions.Deny(perm)
186186}
187187
188188func (w *AppWorkspace) PermissionSkipRequests() bool {
internal/workspace/client_workspace.go+11−8
@@ -244,8 +244,8 @@ func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.Selecte
244244
245245// -- Permissions --
246246
247−func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
248− _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
247+func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) bool {
248+ resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
249249 Permission: proto.PermissionRequest{
250250 ID: perm.ID,
251251 SessionID: perm.SessionID,
@@ -256,12 +256,13 @@ func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
256256 Path: perm.Path,
257257 Params: perm.Params,
258258 },
259− Action: proto.PermissionAllowForSession,
259+ Action: proto.PermissionAllow,
260260 })
261+ return resolved
261262}
262263
263−func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
264− _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
264+func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) bool {
265+ resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
265266 Permission: proto.PermissionRequest{
266267 ID: perm.ID,
267268 SessionID: perm.SessionID,
@@ -272,12 +273,13 @@ func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRe
272273 Path: perm.Path,
273274 Params: perm.Params,
274275 },
275− Action: proto.PermissionAllow,
276+ Action: proto.PermissionAllowForSession,
276277 })
278+ return resolved
277279}
278280
279−func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
280− _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
281+func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) bool {
282+ resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
281283 Permission: proto.PermissionRequest{
282284 ID: perm.ID,
283285 SessionID: perm.SessionID,
@@ -290,6 +292,7 @@ func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
290292 },
291293 Action: proto.PermissionDeny,
292294 })
295+ return resolved
293296}
294297
295298func (w *ClientWorkspace) PermissionSkipRequests() bool {
internal/workspace/client_workspace_test.go+88
@@ -1,9 +1,16 @@
11package workspace
22
33import (
4+ "encoding/json"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
49 "testing"
510
11+ "github.com/charmbracelet/crush/internal/client"
612 "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/permission"
714 "github.com/charmbracelet/crush/internal/proto"
815 "github.com/stretchr/testify/require"
916)
@@ -44,3 +51,84 @@ func TestProtoToMessageToolResult(t *testing.T) {
4451 require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
4552 require.False(t, tr.IsError)
4653}
54+
55+// TestClientWorkspace_PermissionGrantMapping verifies that
56+// PermissionGrant on the ClientWorkspace serializes a one-time grant
57+// (proto.PermissionAllow) and PermissionGrantPersistent serializes a
58+// persistent grant (proto.PermissionAllowForSession). A swap between
59+// these two would silently flip "allow once" into "remember for the
60+// session", and vice versa, so we pin the wire mapping here.
61+func TestClientWorkspace_PermissionGrantMapping(t *testing.T) {
62+ t.Parallel()
63+
64+ cases := []struct {
65+ name string
66+ call func(*ClientWorkspace, permission.PermissionRequest)
67+ want proto.PermissionAction
68+ }{
69+ {
70+ name: "Grant -> PermissionAllow",
71+ call: func(w *ClientWorkspace, p permission.PermissionRequest) {
72+ w.PermissionGrant(p)
73+ },
74+ want: proto.PermissionAllow,
75+ },
76+ {
77+ name: "GrantPersistent -> PermissionAllowForSession",
78+ call: func(w *ClientWorkspace, p permission.PermissionRequest) {
79+ w.PermissionGrantPersistent(p)
80+ },
81+ want: proto.PermissionAllowForSession,
82+ },
83+ {
84+ name: "Deny -> PermissionDeny",
85+ call: func(w *ClientWorkspace, p permission.PermissionRequest) {
86+ w.PermissionDeny(p)
87+ },
88+ want: proto.PermissionDeny,
89+ },
90+ }
91+
92+ for _, tc := range cases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ t.Parallel()
95+
96+ var got proto.PermissionGrant
97+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98+ require.Equal(t, http.MethodPost, r.Method)
99+ require.Equal(t, "/v1/workspaces/ws-1/permissions/grant", r.URL.Path)
100+ body, err := io.ReadAll(r.Body)
101+ require.NoError(t, err)
102+ require.NoError(t, json.Unmarshal(body, &got))
103+ require.NoError(t, json.NewEncoder(w).Encode(proto.PermissionGrantResponse{Resolved: true}))
104+ }))
105+ defer srv.Close()
106+
107+ u, err := url.Parse(srv.URL)
108+ require.NoError(t, err)
109+ c, err := client.NewClient(t.TempDir(), "tcp", u.Host)
110+ require.NoError(t, err)
111+
112+ ws := NewClientWorkspace(c, proto.Workspace{ID: "ws-1"})
113+
114+ perm := permission.PermissionRequest{
115+ ID: "req-1",
116+ SessionID: "sess-1",
117+ ToolCallID: "tc-1",
118+ ToolName: "tool",
119+ Description: "do thing",
120+ Action: "act",
121+ Path: "/tmp/p",
122+ }
123+ tc.call(ws, perm)
124+
125+ require.Equal(t, tc.want, got.Action)
126+ require.Equal(t, "req-1", got.Permission.ID)
127+ require.Equal(t, "sess-1", got.Permission.SessionID)
128+ require.Equal(t, "tc-1", got.Permission.ToolCallID)
129+ require.Equal(t, "tool", got.Permission.ToolName)
130+ require.Equal(t, "act", got.Permission.Action)
131+ require.Equal(t, "/tmp/p", got.Permission.Path)
132+ })
133+ }
134+}
internal/workspace/workspace.go+11−3
@@ -89,9 +89,17 @@ type Workspace interface {
8989 GetDefaultSmallModel(providerID string) config.SelectedModel
9090
9191 // Permissions
92− PermissionGrant(perm permission.PermissionRequest)
93− PermissionGrantPersistent(perm permission.PermissionRequest)
94− PermissionDeny(perm permission.PermissionRequest)
92+ //
93+ // PermissionGrant, PermissionGrantPersistent, and PermissionDeny
94+ // return true if the call resolved the pending request and false if
95+ // it had already been resolved by another subscriber (or is no
96+ // longer pending). A false return is not an error; the modal can
97+ // still close locally because the resolution will arrive via the
98+ // PermissionNotification event stream regardless of which client
99+ // won the race.
100+ PermissionGrant(perm permission.PermissionRequest) bool
101+ PermissionGrantPersistent(perm permission.PermissionRequest) bool
102+ PermissionDeny(perm permission.PermissionRequest) bool
95103 PermissionSkipRequests() bool
96104 PermissionSetSkipRequests(skip bool)
97105