on main help sign in
fix: render permission dialog content in client/server mode (#2877)

Co-authored-by: Charm Crush <crush@charm.land>

commit3625ade17104b8f07145229a3144bf2ba7912033 authorChristian Rocha <christian@rocha.is> dateThu 14 May 2026, 09:45 (5 months ago) parented91948
3 files changed+214−38
internal/proto/permission.go+6 internal/proto/permission_test.go+186 internal/proto/tools.go+22−38
internal/proto/permission.go+6
@@ -118,6 +118,12 @@ func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
118118 return nil, err
119119 }
120120 return params, nil
121+ case AgenticFetchToolName:
122+ var params AgenticFetchPermissionsParams
123+ if err := json.Unmarshal(raw, &params); err != nil {
124+ return nil, err
125+ }
126+ return params, nil
121127 case ViewToolName:
122128 var params ViewPermissionsParams
123129 if err := json.Unmarshal(raw, &params); err != nil {
internal/proto/permission_test.gonew file+186
@@ -0,0 +1,186 @@
1+package proto_test
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/agent/tools"
8+ "github.com/charmbracelet/crush/internal/proto"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// TestPermissionRequestParamsTypeAssertable guards the permission
13+// dialog's type assertions across the client/server boundary. The TUI
14+// asserts PermissionRequest.Params to tools.*PermissionsParams; when
15+// the request round-trips over the SSE wire (server → client), the
16+// decoded value must be the same Go type, otherwise the dialog
17+// renders empty content.
18+func TestPermissionRequestParamsTypeAssertable(t *testing.T) {
19+ t.Parallel()
20+
21+ tests := []struct {
22+ name string
23+ toolName string
24+ params any
25+ assert func(t *testing.T, got any)
26+ }{
27+ {
28+ name: "bash",
29+ toolName: tools.BashToolName,
30+ params: tools.BashPermissionsParams{
31+ Description: "list files",
32+ Command: "ls -la",
33+ WorkingDir: "/tmp",
34+ RunInBackground: false,
35+ },
36+ assert: func(t *testing.T, got any) {
37+ v, ok := got.(tools.BashPermissionsParams)
38+ require.True(t, ok, "params must decode as tools.BashPermissionsParams, got %T", got)
39+ require.Equal(t, "list files", v.Description)
40+ require.Equal(t, "ls -la", v.Command)
41+ require.Equal(t, "/tmp", v.WorkingDir)
42+ },
43+ },
44+ {
45+ name: "edit",
46+ toolName: tools.EditToolName,
47+ params: tools.EditPermissionsParams{
48+ FilePath: "/tmp/x.go",
49+ OldContent: "old",
50+ NewContent: "new",
51+ },
52+ assert: func(t *testing.T, got any) {
53+ v, ok := got.(tools.EditPermissionsParams)
54+ require.True(t, ok, "params must decode as tools.EditPermissionsParams, got %T", got)
55+ require.Equal(t, "/tmp/x.go", v.FilePath)
56+ require.Equal(t, "old", v.OldContent)
57+ require.Equal(t, "new", v.NewContent)
58+ },
59+ },
60+ {
61+ name: "write",
62+ toolName: tools.WriteToolName,
63+ params: tools.WritePermissionsParams{
64+ FilePath: "/tmp/x.go",
65+ NewContent: "new",
66+ },
67+ assert: func(t *testing.T, got any) {
68+ v, ok := got.(tools.WritePermissionsParams)
69+ require.True(t, ok, "params must decode as tools.WritePermissionsParams, got %T", got)
70+ require.Equal(t, "/tmp/x.go", v.FilePath)
71+ require.Equal(t, "new", v.NewContent)
72+ },
73+ },
74+ {
75+ name: "multiedit",
76+ toolName: tools.MultiEditToolName,
77+ params: tools.MultiEditPermissionsParams{
78+ FilePath: "/tmp/x.go",
79+ OldContent: "old",
80+ NewContent: "new",
81+ },
82+ assert: func(t *testing.T, got any) {
83+ v, ok := got.(tools.MultiEditPermissionsParams)
84+ require.True(t, ok, "params must decode as tools.MultiEditPermissionsParams, got %T", got)
85+ require.Equal(t, "/tmp/x.go", v.FilePath)
86+ },
87+ },
88+ {
89+ name: "ls",
90+ toolName: tools.LSToolName,
91+ params: tools.LSPermissionsParams{
92+ Path: "/tmp",
93+ Ignore: []string{".git"},
94+ Depth: 2,
95+ },
96+ assert: func(t *testing.T, got any) {
97+ v, ok := got.(tools.LSPermissionsParams)
98+ require.True(t, ok, "params must decode as tools.LSPermissionsParams, got %T", got)
99+ require.Equal(t, "/tmp", v.Path)
100+ require.Equal(t, []string{".git"}, v.Ignore)
101+ require.Equal(t, 2, v.Depth)
102+ },
103+ },
104+ {
105+ name: "view",
106+ toolName: tools.ViewToolName,
107+ params: tools.ViewPermissionsParams{
108+ FilePath: "/tmp/x.go",
109+ Offset: 10,
110+ Limit: 100,
111+ },
112+ assert: func(t *testing.T, got any) {
113+ v, ok := got.(tools.ViewPermissionsParams)
114+ require.True(t, ok, "params must decode as tools.ViewPermissionsParams, got %T", got)
115+ require.Equal(t, "/tmp/x.go", v.FilePath)
116+ },
117+ },
118+ {
119+ name: "fetch",
120+ toolName: tools.FetchToolName,
121+ params: tools.FetchPermissionsParams{
122+ URL: "https://example.com",
123+ Format: "text",
124+ },
125+ assert: func(t *testing.T, got any) {
126+ v, ok := got.(tools.FetchPermissionsParams)
127+ require.True(t, ok, "params must decode as tools.FetchPermissionsParams, got %T", got)
128+ require.Equal(t, "https://example.com", v.URL)
129+ },
130+ },
131+ {
132+ name: "download",
133+ toolName: tools.DownloadToolName,
134+ params: tools.DownloadPermissionsParams{
135+ URL: "https://example.com/x.zip",
136+ FilePath: "/tmp/x.zip",
137+ Timeout: 30,
138+ },
139+ assert: func(t *testing.T, got any) {
140+ v, ok := got.(tools.DownloadPermissionsParams)
141+ require.True(t, ok, "params must decode as tools.DownloadPermissionsParams, got %T", got)
142+ require.Equal(t, "https://example.com/x.zip", v.URL)
143+ require.Equal(t, "/tmp/x.zip", v.FilePath)
144+ },
145+ },
146+ {
147+ name: "agentic_fetch",
148+ toolName: tools.AgenticFetchToolName,
149+ params: tools.AgenticFetchPermissionsParams{
150+ URL: "https://example.com",
151+ Prompt: "summarize this page",
152+ },
153+ assert: func(t *testing.T, got any) {
154+ v, ok := got.(tools.AgenticFetchPermissionsParams)
155+ require.True(t, ok, "params must decode as tools.AgenticFetchPermissionsParams, got %T", got)
156+ require.Equal(t, "https://example.com", v.URL)
157+ require.Equal(t, "summarize this page", v.Prompt)
158+ },
159+ },
160+ }
161+
162+ for _, tc := range tests {
163+ t.Run(tc.name, func(t *testing.T) {
164+ t.Parallel()
165+
166+ // Build a server-side request with the tool's concrete
167+ // params type, marshal to JSON (the wire path), then
168+ // decode back through proto.PermissionRequest.
169+ outbound := proto.PermissionRequest{
170+ ID: "perm-1",
171+ SessionID: "sess-1",
172+ ToolCallID: "call-1",
173+ ToolName: tc.toolName,
174+ Path: "/tmp",
175+ Params: tc.params,
176+ }
177+ data, err := json.Marshal(outbound)
178+ require.NoError(t, err)
179+
180+ var inbound proto.PermissionRequest
181+ require.NoError(t, json.Unmarshal(data, &inbound))
182+
183+ tc.assert(t, inbound.Params)
184+ })
185+ }
186+}
internal/proto/tools.go+22−38
@@ -1,5 +1,12 @@
11package proto
22
3+// The wire schema for per-tool permission parameters is owned by the
4+// tool itself, not duplicated here. We alias the canonical types so
5+// there is exactly one source of truth and so values survive a
6+// round-trip across the client/server boundary as the same Go type
7+// the UI asserts on.
8+import "github.com/charmbracelet/crush/internal/agent/tools"
9+
310// ToolResponseType represents the type of tool response.
411type ToolResponseType string
512
@@ -25,10 +32,7 @@ type BashParams struct {
2532}
2633
2734// BashPermissionsParams represents the permission parameters for the bash tool.
28−type BashPermissionsParams struct {
29− Command string `json:"command"`
30− Timeout int `json:"timeout"`
31−}
35+type BashPermissionsParams = tools.BashPermissionsParams
3236
3337// BashResponseMetadata represents the metadata for a bash tool response.
3438type BashResponseMetadata struct {
@@ -53,11 +57,7 @@ type DownloadParams struct {
5357}
5458
5559// DownloadPermissionsParams represents the permission parameters for the download tool.
56−type DownloadPermissionsParams struct {
57− URL string `json:"url"`
58− FilePath string `json:"file_path"`
59− Timeout int `json:"timeout,omitempty"`
60−}
60+type DownloadPermissionsParams = tools.DownloadPermissionsParams
6161
6262const EditToolName = "edit"
6363
@@ -70,11 +70,7 @@ type EditParams struct {
7070}
7171
7272// EditPermissionsParams represents the permission parameters for the edit tool.
73−type EditPermissionsParams struct {
74− FilePath string `json:"file_path"`
75− OldContent string `json:"old_content,omitempty"`
76− NewContent string `json:"new_content,omitempty"`
77−}
73+type EditPermissionsParams = tools.EditPermissionsParams
7874
7975// EditResponseMetadata represents the metadata for an edit tool response.
8076type EditResponseMetadata struct {
@@ -94,11 +90,14 @@ type FetchParams struct {
9490}
9591
9692// FetchPermissionsParams represents the permission parameters for the fetch tool.
97−type FetchPermissionsParams struct {
98− URL string `json:"url"`
99− Format string `json:"format"`
100− Timeout int `json:"timeout,omitempty"`
101−}
93+type FetchPermissionsParams = tools.FetchPermissionsParams
94+
95+// AgenticFetchToolName is the name of the agentic_fetch tool.
96+const AgenticFetchToolName = tools.AgenticFetchToolName
97+
98+// AgenticFetchPermissionsParams represents the permission parameters for the
99+// agentic_fetch tool.
100+type AgenticFetchPermissionsParams = tools.AgenticFetchPermissionsParams
102101
103102const GlobToolName = "glob"
104103
@@ -139,10 +138,7 @@ type LSParams struct {
139138}
140139
141140// LSPermissionsParams represents the permission parameters for the ls tool.
142−type LSPermissionsParams struct {
143− Path string `json:"path"`
144− Ignore []string `json:"ignore"`
145−}
141+type LSPermissionsParams = tools.LSPermissionsParams
146142
147143// TreeNode represents a node in a directory tree.
148144type TreeNode struct {
@@ -174,11 +170,7 @@ type MultiEditParams struct {
174170}
175171
176172// MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
177−type MultiEditPermissionsParams struct {
178− FilePath string `json:"file_path"`
179− OldContent string `json:"old_content,omitempty"`
180− NewContent string `json:"new_content,omitempty"`
181−}
173+type MultiEditPermissionsParams = tools.MultiEditPermissionsParams
182174
183175// MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
184176type MultiEditResponseMetadata struct {
@@ -215,11 +207,7 @@ type ViewParams struct {
215207}
216208
217209// ViewPermissionsParams represents the permission parameters for the view tool.
218−type ViewPermissionsParams struct {
219− FilePath string `json:"file_path"`
220− Offset int `json:"offset"`
221− Limit int `json:"limit"`
222−}
210+type ViewPermissionsParams = tools.ViewPermissionsParams
223211
224212// ViewResponseMetadata represents the metadata for a view tool response.
225213type ViewResponseMetadata struct {
@@ -236,11 +224,7 @@ type WriteParams struct {
236224}
237225
238226// WritePermissionsParams represents the permission parameters for the write tool.
239−type WritePermissionsParams struct {
240− FilePath string `json:"file_path"`
241− OldContent string `json:"old_content,omitempty"`
242− NewContent string `json:"new_content,omitempty"`
243−}
227+type WritePermissionsParams = tools.WritePermissionsParams
244228
245229// WriteResponseMetadata represents the metadata for a write tool response.
246230type WriteResponseMetadata struct {