on main help sign in
fix(auth): support oauth in client-server mode
0c99e77Christian Rocha
internal/agent/tools/mcp/init_test.go+4−5
@@ -376,7 +376,7 @@ func TestCreateTransport_HeadersResolution(t *testing.T) {
376
376
377 t.Run("sse unset var header drops silently", func(t *testing.T) {
377 t.Run("sse unset var header drops silently", func(t *testing.T) {
378 t.Parallel()
378 t.Parallel()
379− // Pinning test for design decision #18 + lenient nounset:
379+ // Pinning test for empty-header drop + lenient nounset:
380 // a header whose value resolves to "" (here because the
380 // a header whose value resolves to "" (here because the
381 // bare $VAR is unset) is omitted from the round tripper
381 // bare $VAR is unset) is omitted from the round tripper
382 // rather than sent as "X-Header:". Guards against a
382 // rather than sent as "X-Header:". Guards against a
@@ -488,10 +488,9 @@ func TestCreateSession_ResolutionFailureUpdatesState(t *testing.T) {
488 },
488 },
489 {
489 {
490 // Bare $MISSING in a header resolves to "" silently
490 // Bare $MISSING in a header resolves to "" silently
491− // and is then dropped (design decision #18). The
491+ // and is then dropped. The "header Authorization"
492− // "header Authorization" wrap only surfaces on a
492+ // wrap only surfaces on a $(cmd) failure; that is
493− // $(cmd) failure; that is what this subtest now
494− // pins for the SSE path.
493+ // what this subtest now pins for the SSE path.
495 name: "sse header failure",
494 name: "sse header failure",
496 mcpName: "test-sse-header-fail",
495 mcpName: "test-sse-header-fail",
497 cfg: config.MCPConfig{
496 cfg: config.MCPConfig{
internal/client/config.go+37−6
@@ -8,6 +8,7 @@ import (
8
8
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/oauth"
10 "github.com/charmbracelet/crush/internal/oauth"
11+ "github.com/charmbracelet/crush/internal/proto"
11)
12)
12
13
13// SetConfigField sets a config key/value pair on the server.
14// SetConfigField sets a config key/value pair on the server.
@@ -76,13 +77,43 @@ func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Sco
76 return nil
77 return nil
77}
78}
78
79
79−// SetProviderAPIKey sets a provider API key on the server.
80+// SetProviderAPIKey sets a provider API key on the server. The wire
81+// format tags the credential with an explicit Kind so the server can
82+// decode it back into the right Go type — JSON's `any` loses that
83+// information across the socket.
80func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
84func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
85+ var (
86+ kind proto.APIKeyKind
87+ raw json.RawMessage
88+ )
89+ switch v := apiKey.(type) {
90+ case string:
91+ kind = proto.APIKeyKindString
92+ b, err := json.Marshal(v)
93+ if err != nil {
94+ return fmt.Errorf("failed to marshal api key string: %w", err)
95+ }
96+ raw = b
97+ case *oauth.Token:
98+ if v == nil {
99+ return fmt.Errorf("oauth token is nil")
100+ }
101+ kind = proto.APIKeyKindOAuth
102+ b, err := json.Marshal(v)
103+ if err != nil {
104+ return fmt.Errorf("failed to marshal oauth token: %w", err)
105+ }
106+ raw = b
107+ default:
108+ return fmt.Errorf("unsupported api key type %T", apiKey)
109+ }
110+
81− rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(struct {
111+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(proto.ConfigProviderKeyRequest{
82− Scope config.Scope `json:"scope"`
112+ Scope: scope,
83− ProviderID string `json:"provider_id"`
113+ ProviderID: providerID,
114+ Kind: kind,
84− APIKey any `json:"api_key"`
115+ APIKey: raw,
85− }{Scope: scope, ProviderID: providerID, APIKey: apiKey}), http.Header{"Content-Type": []string{"application/json"}})
116+ }), http.Header{"Content-Type": []string{"application/json"}})
86 if err != nil {
117 if err != nil {
87 return fmt.Errorf("failed to set provider API key: %w", err)
118 return fmt.Errorf("failed to set provider API key: %w", err)
88 }
119 }
internal/client/config_test.gonew file+101
@@ -0,0 +1,101 @@
1+package client
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "testing"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/oauth"
14+ "github.com/charmbracelet/crush/internal/proto"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// captureClient returns a Client that talks to the given test server,
19+// plus a channel receiving the parsed request body for each call.
20+func captureClient(t *testing.T, srv *httptest.Server) *Client {
21+ t.Helper()
22+ u, err := url.Parse(srv.URL)
23+ require.NoError(t, err)
24+ c, err := NewClient(t.TempDir(), "tcp", u.Host)
25+ require.NoError(t, err)
26+ return c
27+}
28+
29+func TestSetProviderAPIKeyStringSendsKind(t *testing.T) {
30+ t.Parallel()
31+
32+ var got proto.ConfigProviderKeyRequest
33+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+ body, err := io.ReadAll(r.Body)
35+ require.NoError(t, err)
36+ require.NoError(t, json.Unmarshal(body, &got))
37+ w.WriteHeader(http.StatusOK)
38+ }))
39+ defer srv.Close()
40+
41+ c := captureClient(t, srv)
42+ require.NoError(t, c.SetProviderAPIKey(context.Background(), "ws1", config.ScopeGlobal, "openai", "sk-xyz"))
43+
44+ require.Equal(t, proto.APIKeyKindString, got.Kind)
45+ require.Equal(t, "openai", got.ProviderID)
46+ require.Equal(t, config.ScopeGlobal, got.Scope)
47+ decoded, err := got.DecodeAPIKey()
48+ require.NoError(t, err)
49+ require.Equal(t, "sk-xyz", decoded)
50+}
51+
52+func TestSetProviderAPIKeyOAuthSendsKind(t *testing.T) {
53+ t.Parallel()
54+
55+ var got proto.ConfigProviderKeyRequest
56+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+ body, err := io.ReadAll(r.Body)
58+ require.NoError(t, err)
59+ require.NoError(t, json.Unmarshal(body, &got))
60+ w.WriteHeader(http.StatusOK)
61+ }))
62+ defer srv.Close()
63+
64+ tok := &oauth.Token{AccessToken: "a", RefreshToken: "r", ExpiresIn: 60, ExpiresAt: 1234567890}
65+ c := captureClient(t, srv)
66+ require.NoError(t, c.SetProviderAPIKey(context.Background(), "ws1", config.ScopeGlobal, "hyper", tok))
67+
68+ require.Equal(t, proto.APIKeyKindOAuth, got.Kind)
69+ decoded, err := got.DecodeAPIKey()
70+ require.NoError(t, err)
71+ require.Equal(t, tok, decoded.(*oauth.Token))
72+}
73+
74+func TestSetProviderAPIKeyUnsupportedTypeFailsLocally(t *testing.T) {
75+ t.Parallel()
76+
77+ called := false
78+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
79+ called = true
80+ w.WriteHeader(http.StatusOK)
81+ }))
82+ defer srv.Close()
83+
84+ c := captureClient(t, srv)
85+ err := c.SetProviderAPIKey(context.Background(), "ws1", config.ScopeGlobal, "x", 42)
86+ require.Error(t, err)
87+ require.Contains(t, err.Error(), "unsupported api key type")
88+ require.False(t, called, "server should not have been reached")
89+}
90+
91+func TestSetProviderAPIKeyNilOAuthFailsLocally(t *testing.T) {
92+ t.Parallel()
93+
94+ c := captureClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95+ w.WriteHeader(http.StatusOK)
96+ })))
97+
98+ var tok *oauth.Token
99+ err := c.SetProviderAPIKey(context.Background(), "ws1", config.ScopeGlobal, "x", tok)
100+ require.Error(t, err)
101+}
internal/config/config.go+8−14
@@ -113,8 +113,7 @@ type ProviderConfig struct {
113 // $(cmd) work the same way they do in MCP headers. A header whose
113 // $(cmd) work the same way they do in MCP headers. A header whose
114 // value resolves to the empty string (unset bare $VAR under
114 // value resolves to the empty string (unset bare $VAR under
115 // lenient nounset, $(echo), or literal "") is omitted from the
115 // lenient nounset, $(echo), or literal "") is omitted from the
116− // outgoing request rather than sent as "Header:". See PLAN.md
116+ // outgoing request rather than sent as "Header:".
117− // Phase 2 design decision #18.
118 ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"description=Additional HTTP headers to send with requests"`
117 ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"description=Additional HTTP headers to send with requests"`
119 // ExtraBody is merged verbatim into OpenAI-compatible request
118 // ExtraBody is merged verbatim into OpenAI-compatible request
120 // bodies. String values are NOT shell-expanded: this is a plain
119 // bodies. String values are NOT shell-expanded: this is a plain
@@ -123,7 +122,7 @@ type ProviderConfig struct {
123 // recursive walker guessing at intent. If you need an env-var-
122 // recursive walker guessing at intent. If you need an env-var-
124 // driven value at request time, put it in extra_headers, or in
123 // driven value at request time, put it in extra_headers, or in
125 // the provider's top-level api_key / base_url, all of which do
124 // the provider's top-level api_key / base_url, all of which do
126− // expand. See PLAN.md Phase 2 design decision #16.
125+ // expand.
127 ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies\\, only works with openai-compatible providers"`
126 ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies\\, only works with openai-compatible providers"`
128
127
129 ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for this provider"`
128 ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for this provider"`
@@ -196,7 +195,7 @@ type MCPConfig struct {
196 // work. A header whose value resolves to the empty string (unset
195 // work. A header whose value resolves to the empty string (unset
197 // bare $VAR under lenient nounset, $(echo), or literal "") is
196 // bare $VAR under lenient nounset, $(echo), or literal "") is
198 // omitted from the outgoing request rather than sent as
197 // omitted from the outgoing request rather than sent as
199− // "Header:". See PLAN.md Phase 2 design decision #18.
198+ // "Header:".
200 Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"`
199 Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"`
201}
200}
202
201
@@ -398,8 +397,7 @@ func (m MCPConfig) ResolvedURL(r VariableResolver) (string, error) {
398// under lenient nounset, $(echo), or literal "") is omitted from the
397// under lenient nounset, $(echo), or literal "") is omitted from the
399// returned map — sending "X-Auth:" with an empty value is rejected by
398// returned map — sending "X-Auth:" with an empty value is rejected by
400// some providers and the user's intent in "optional, env-gated
399// some providers and the user's intent in "optional, env-gated
401−// header" is clearly "absent when the var isn't set." See PLAN.md
400+// header" is clearly "absent when the var isn't set."
402−// Phase 2 design decision #18.
403//
401//
404// See ResolvedEnv for guidance on picking a resolver.
402// See ResolvedEnv for guidance on picking a resolver.
405func (m MCPConfig) ResolvedHeaders(r VariableResolver) (map[string]string, error) {
403func (m MCPConfig) ResolvedHeaders(r VariableResolver) (map[string]string, error) {
@@ -435,13 +433,11 @@ func (m MCPConfig) ResolvedHeaders(r VariableResolver) (map[string]string, error
435// errors.Is/As continues to work.
433// errors.Is/As continues to work.
436//
434//
437// Empty resolved values are kept (a deliberate "empty positional arg"
435// Empty resolved values are kept (a deliberate "empty positional arg"
438−// like --flag "" is sometimes valid), matching MCPConfig.ResolvedArgs;
436+// like --flag "" is sometimes valid), matching MCPConfig.ResolvedArgs.
439−// see PLAN.md Phase 2 design decision #18.
440//
437//
441// The resolver choice matters: in server mode pass the shell resolver
438// The resolver choice matters: in server mode pass the shell resolver
442// so $VAR / $(cmd) expand; in client mode pass IdentityResolver so the
439// so $VAR / $(cmd) expand; in client mode pass IdentityResolver so the
443−// template is forwarded verbatim. See PLAN.md Phase 2 design decision
440+// template is forwarded verbatim.
444−// #13.
445func (l LSPConfig) ResolvedArgs(r VariableResolver) ([]string, error) {
441func (l LSPConfig) ResolvedArgs(r VariableResolver) ([]string, error) {
446 if len(l.Args) == 0 {
442 if len(l.Args) == 0 {
447 return nil, nil
443 return nil, nil
@@ -465,15 +461,13 @@ func (l LSPConfig) ResolvedArgs(r VariableResolver) ([]string, error) {
465// continues to work.
461// continues to work.
466//
462//
467// Empty resolved values are kept ("FOO=" is a legitimate request;
463// Empty resolved values are kept ("FOO=" is a legitimate request;
468−// opt out via ${VAR:+...}), matching MCPConfig.ResolvedEnv; see
464+// opt out via ${VAR:+...}), matching MCPConfig.ResolvedEnv.
469−// PLAN.md Phase 2 design decision #18.
470//
465//
471// Shape note: this returns map[string]string rather than the []string
466// Shape note: this returns map[string]string rather than the []string
472// shape MCPConfig.ResolvedEnv uses because the consumer
467// shape MCPConfig.ResolvedEnv uses because the consumer
473// (powernap.ClientConfig.Environment in internal/lsp/client.go) takes
468// (powernap.ClientConfig.Environment in internal/lsp/client.go) takes
474// a map directly — returning a []string here would only force a
469// a map directly — returning a []string here would only force a
475−// round-trip back to a map at the call site. See PLAN.md Phase 2
470+// round-trip back to a map at the call site.
476−// design decision #13.
477//
471//
478// See ResolvedArgs for guidance on picking a resolver.
472// See ResolvedArgs for guidance on picking a resolver.
479func (l LSPConfig) ResolvedEnv(r VariableResolver) (map[string]string, error) {
473func (l LSPConfig) ResolvedEnv(r VariableResolver) (map[string]string, error) {
internal/config/load.go+2−4
@@ -230,8 +230,7 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
230 // a failing $(...) aborts the provider load with a clear
230 // a failing $(...) aborts the provider load with a clear
231 // message, and a header that resolves to the empty string
231 // message, and a header that resolves to the empty string
232 // (unset bare $VAR under lenient nounset, $(echo), or literal
232 // (unset bare $VAR under lenient nounset, $(echo), or literal
233− // "") is dropped from the outgoing request. See PLAN.md
233+ // "") is dropped from the outgoing request.
234− // Phase 2 design decisions #14 and #18.
235 for k, v := range headers {
234 for k, v := range headers {
236 resolved, err := resolver.ResolveValue(v)
235 resolved, err := resolver.ResolveValue(v)
237 if err != nil {
236 if err != nil {
@@ -390,8 +389,7 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
390 }
389 }
391
390
392 // Custom-provider headers share the MCP error contract; see
391 // Custom-provider headers share the MCP error contract; see
393− // the known-provider loop above and PLAN.md Phase 2 design
392+ // the known-provider loop above.
394− // decisions #14 and #18.
395 for k, v := range providerConfig.ExtraHeaders {
393 for k, v := range providerConfig.ExtraHeaders {
396 resolved, err := resolver.ResolveValue(v)
394 resolved, err := resolver.ResolveValue(v)
397 if err != nil {
395 if err != nil {
internal/config/load_test.go+14−17
@@ -1828,12 +1828,10 @@ func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T)
1828 require.Equal(t, "env-api-key", pc.APIKey)
1828 require.Equal(t, "env-api-key", pc.APIKey)
1829}
1829}
1830
1830
1831−// TestConfig_configureProviders_ProviderHeaderResolveError pins
1831+// TestConfig_configureProviders_ProviderHeaderResolveError verifies
1832−// Phase 2 design decision #14: a failing $(cmd) in a provider header
1832+// that a failing $(cmd) in a provider header fails the provider load
1833−// must fail the provider load with a clear message that names the
1833+// with a clear message that names the offending header. Provider
1834−// offending header. The Phase 1 log-and-continue divergence at
1835−// load.go:225 is gone; provider headers now share the MCP error
1834+// headers share the MCP error contract.
1836−// contract.
1837func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1835func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1838 knownProviders := []catwalk.Provider{
1836 knownProviders := []catwalk.Provider{
1839 {
1837 {
@@ -1868,12 +1866,11 @@ func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1868 require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1866 require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1869}
1867}
1870
1868
1871−// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads pins
1869+// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads
1872−// Phase 2 design decisions #11 and #18 from the provider angle: a
1873−// Catwalk-style default header like
1870+// verifies that a Catwalk-style default header like
1874−// "OpenAI-Organization": "$OPENAI_ORG_ID" must load cleanly under
1871+// "OpenAI-Organization": "$OPENAI_ORG_ID" loads cleanly under lenient
1875−// lenient nounset (unset → "" → header dropped), not fail the load
1872+// nounset (unset → "" → header dropped), and does not fail the load
1876−// and not leave the literal template on the wire.
1873+// or leave the literal template on the wire.
1877func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1874func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1878 knownProviders := []catwalk.Provider{
1875 knownProviders := []catwalk.Provider{
1879 {
1876 {
@@ -1979,11 +1976,11 @@ func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1979 require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1976 require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1980}
1977}
1981
1978
1982−// TestConfig_configureProviders_UnsetAPIKeySkipsProvider pins Phase 2
1979+// TestConfig_configureProviders_UnsetAPIKeySkipsProvider verifies that
1983−// Step 12 / design decision #15: under the lenient-nounset shell
1984−// resolver, $UNSET_API_KEY expands to ("", nil) rather than ("", err),
1980+// under the lenient-nounset shell resolver, $UNSET_API_KEY expands to
1981+// ("", nil) rather than ("", err), and the existing
1985−// and the existing `v == "" || err != nil` skip path at load.go:331
1982+// `v == "" || err != nil` skip path at load.go:331 still drops the
1986−// still drops the provider. The slog.Warn line is emitted on the same
1983+// provider. The slog.Warn line is emitted on the same
1987// path but is not asserted here — internal/config/load_test.go's
1984// path but is not asserted here — internal/config/load_test.go's
1988// TestMain replaces the default slog handler with an io.Discard
1985// TestMain replaces the default slog handler with an io.Discard
1989// writer, so capturing that log line would require mid-test handler
1986// writer, so capturing that log line would require mid-test handler
internal/config/mcp_resolved_url_test.go+1−1
@@ -46,7 +46,7 @@ func TestMCPConfig_ResolvedURL(t *testing.T) {
46
46
47 t.Run("unset var expands to empty under lenient default", func(t *testing.T) {
47 t.Run("unset var expands to empty under lenient default", func(t *testing.T) {
48 t.Parallel()
48 t.Parallel()
49− // Phase 2 defaults to nounset-off: bare $VAR on an unset
49+ // The default is nounset-off: bare $VAR on an unset
50 // variable expands to "" rather than erroring. Here the
50 // variable expands to "" rather than erroring. Here the
51 // host collapses to empty, so the caller sees a malformed
51 // host collapses to empty, so the caller sees a malformed
52 // URL rather than a resolver error; that's the expected
52 // URL rather than a resolver error; that's the expected
internal/config/resolve_real_test.go+15−17
@@ -159,21 +159,20 @@ func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
159 require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
159 require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
160}
160}
161
161
162−// TestResolvedEnv_RealShell_UnsetExpandsEmpty pins Phase 2's lenient
162+// TestResolvedEnv_RealShell_UnsetExpandsEmpty pins the lenient
163// default: an unset bare $VAR expands to the empty string, matching
163// default: an unset bare $VAR expands to the empty string, matching
164−// bash. The silent-empty-credential class of bug that motivated Phase
164+// bash. The silent-empty-credential class of bug is prevented by the
165−// 1's nounset-on default is already prevented by the pure-function
166−// error-returning contract of ResolvedEnv, so we no longer rely on
165+// pure-function error-returning contract of ResolvedEnv, so we don't
167−// nounset to catch typo'd variable names. Users who want strict
166+// rely on nounset to catch typo'd variable names. Users who want
168−// behaviour for a required credential opt in per-reference with
167+// strict behaviour for a required credential opt in per-reference
169−// ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
168+// with ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
170func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
169func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
171 t.Parallel()
170 t.Parallel()
172
171
173 m := MCPConfig{Env: map[string]string{
172 m := MCPConfig{Env: map[string]string{
174− // Intentional typo: user meant $FORGEJO_TOKEN. Under Phase 2
173+ // Intentional typo: user meant $FORGEJO_TOKEN. Under the
175− // defaults this expands to "" rather than erroring, matching
174+ // lenient default this expands to "" rather than erroring,
176− // bash's behaviour on bare $VAR.
175+ // matching bash's behaviour on bare $VAR.
177 "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
176 "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
178 }}
177 }}
179 got, err := m.ResolvedEnv(realShellResolver(nil))
178 got, err := m.ResolvedEnv(realShellResolver(nil))
@@ -259,13 +258,12 @@ func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
259 require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
258 require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
260}
259}
261
260
262−// TestResolvedHeaders_RealShell_DropEmpty pins Phase 2 design
261+// TestResolvedHeaders_RealShell_DropEmpty verifies that a header
263−// decision #18 on the MCP side: a header whose value resolves to the
264−// empty string is omitted from the returned map. Covers the three
262+// whose value resolves to the empty string is omitted from the
265−// ways a value can legitimately land on empty — unset bare $VAR
263+// returned map. Covers the three ways a value can legitimately land
266−// under lenient nounset, a literal "", and a non-failing command
264+// on empty — unset bare $VAR under lenient nounset, a literal "",
267−// whose stdout is empty — and also pins that a failing $(cmd) still
265+// and a non-failing command whose stdout is empty — and also pins
268−// errors rather than silently dropping.
266+// that a failing $(cmd) still errors rather than silently dropping.
269func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
267func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
270 t.Parallel()
268 t.Parallel()
271
269
internal/proto/requests.go+50−5
@@ -1,6 +1,12 @@
1package proto
1package proto
2
2
3+import (
4+ "encoding/json"
5+ "fmt"
6+
3−import "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/oauth"
9+)
4
10
5// ConfigSetRequest represents a request to set a config field.
11// ConfigSetRequest represents a request to set a config field.
6type ConfigSetRequest struct {
12type ConfigSetRequest struct {
@@ -28,11 +34,50 @@ type ConfigCompactRequest struct {
28 Enabled bool `json:"enabled"`
34 Enabled bool `json:"enabled"`
29}
35}
30
36
37+// APIKeyKind discriminates the kind of credential carried in a
38+// ConfigProviderKeyRequest. JSON's `any` loses Go type information, so
39+// the wire format names the kind explicitly and the server decodes
40+// APIKey accordingly.
41+type APIKeyKind string
42+
43+const (
44+ // APIKeyKindString is a plain string API key.
45+ APIKeyKindString APIKeyKind = "string"
46+ // APIKeyKindOAuth is an oauth.Token credential.
47+ APIKeyKindOAuth APIKeyKind = "oauth"
48+)
49+
31−// ConfigProviderKeyRequest represents a request to set a provider API key.
50+// ConfigProviderKeyRequest represents a request to set a provider API
51+// key. APIKey is the raw JSON for the credential; Kind selects the
52+// concrete Go type APIKey should be decoded into via DecodeAPIKey.
32type ConfigProviderKeyRequest struct {
53type ConfigProviderKeyRequest struct {
33− Scope config.Scope `json:"scope"`
54+ Scope config.Scope `json:"scope"`
34− ProviderID string `json:"provider_id"`
55+ ProviderID string `json:"provider_id"`
35− APIKey any `json:"api_key"`
56+ Kind APIKeyKind `json:"kind"`
57+ APIKey json.RawMessage `json:"api_key"`
58+}
59+
60+// DecodeAPIKey decodes APIKey into the Go type indicated by Kind. It
61+// returns a string for APIKeyKindString and a *oauth.Token for
62+// APIKeyKindOAuth. An unknown kind or malformed payload is reported
63+// as an error.
64+func (r ConfigProviderKeyRequest) DecodeAPIKey() (any, error) {
65+ switch r.Kind {
66+ case APIKeyKindString:
67+ var s string
68+ if err := json.Unmarshal(r.APIKey, &s); err != nil {
69+ return nil, fmt.Errorf("decode api key string: %w", err)
70+ }
71+ return s, nil
72+ case APIKeyKindOAuth:
73+ var tok oauth.Token
74+ if err := json.Unmarshal(r.APIKey, &tok); err != nil {
75+ return nil, fmt.Errorf("decode api key oauth token: %w", err)
76+ }
77+ return &tok, nil
78+ default:
79+ return nil, fmt.Errorf("unsupported api key kind %q", r.Kind)
80+ }
36}
81}
37
82
38// ConfigRefreshOAuthRequest represents a request to refresh an OAuth token.
83// ConfigRefreshOAuthRequest represents a request to refresh an OAuth token.
internal/proto/requests_test.gonew file+106
@@ -0,0 +1,106 @@
1+package proto_test
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/oauth"
9+ "github.com/charmbracelet/crush/internal/proto"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestConfigProviderKeyRequestStringRoundTrip(t *testing.T) {
14+ t.Parallel()
15+
16+ apiKey, err := json.Marshal("sk-test-123")
17+ require.NoError(t, err)
18+
19+ src := proto.ConfigProviderKeyRequest{
20+ Scope: config.ScopeGlobal,
21+ ProviderID: "openai",
22+ Kind: proto.APIKeyKindString,
23+ APIKey: apiKey,
24+ }
25+ b, err := json.Marshal(src)
26+ require.NoError(t, err)
27+
28+ var got proto.ConfigProviderKeyRequest
29+ require.NoError(t, json.Unmarshal(b, &got))
30+ require.Equal(t, proto.APIKeyKindString, got.Kind)
31+
32+ decoded, err := got.DecodeAPIKey()
33+ require.NoError(t, err)
34+ s, ok := decoded.(string)
35+ require.True(t, ok, "expected string, got %T", decoded)
36+ require.Equal(t, "sk-test-123", s)
37+}
38+
39+func TestConfigProviderKeyRequestOAuthRoundTrip(t *testing.T) {
40+ t.Parallel()
41+
42+ tok := &oauth.Token{
43+ AccessToken: "access",
44+ RefreshToken: "refresh",
45+ ExpiresIn: 60,
46+ ExpiresAt: 1234567890,
47+ }
48+ apiKey, err := json.Marshal(tok)
49+ require.NoError(t, err)
50+
51+ src := proto.ConfigProviderKeyRequest{
52+ Scope: config.ScopeGlobal,
53+ ProviderID: "hyper",
54+ Kind: proto.APIKeyKindOAuth,
55+ APIKey: apiKey,
56+ }
57+ b, err := json.Marshal(src)
58+ require.NoError(t, err)
59+
60+ var got proto.ConfigProviderKeyRequest
61+ require.NoError(t, json.Unmarshal(b, &got))
62+ require.Equal(t, proto.APIKeyKindOAuth, got.Kind)
63+
64+ decoded, err := got.DecodeAPIKey()
65+ require.NoError(t, err)
66+ gotTok, ok := decoded.(*oauth.Token)
67+ require.True(t, ok, "expected *oauth.Token, got %T", decoded)
68+ require.Equal(t, tok, gotTok)
69+}
70+
71+func TestConfigProviderKeyRequestUnknownKind(t *testing.T) {
72+ t.Parallel()
73+
74+ req := proto.ConfigProviderKeyRequest{
75+ Kind: proto.APIKeyKind("bogus"),
76+ APIKey: json.RawMessage(`"x"`),
77+ }
78+ _, err := req.DecodeAPIKey()
79+ require.Error(t, err)
80+ require.Contains(t, err.Error(), "bogus")
81+}
82+
83+func TestConfigProviderKeyRequestMalformedPayload(t *testing.T) {
84+ t.Parallel()
85+
86+ cases := []struct {
87+ name string
88+ kind proto.APIKeyKind
89+ raw string
90+ }{
91+ {"string kind with object payload", proto.APIKeyKindString, `{"foo":"bar"}`},
92+ {"oauth kind with string payload", proto.APIKeyKindOAuth, `"not-a-token"`},
93+ {"oauth kind with invalid json", proto.APIKeyKindOAuth, `{`},
94+ }
95+ for _, tc := range cases {
96+ t.Run(tc.name, func(t *testing.T) {
97+ t.Parallel()
98+ req := proto.ConfigProviderKeyRequest{
99+ Kind: tc.kind,
100+ APIKey: json.RawMessage(tc.raw),
101+ }
102+ _, err := req.DecodeAPIKey()
103+ require.Error(t, err)
104+ })
105+ }
106+}
internal/server/config.go+8−1
@@ -145,7 +145,14 @@ func (c *controllerV1) handlePostWorkspaceConfigProviderKey(w http.ResponseWrite
145 return
145 return
146 }
146 }
147
147
148+ apiKey, err := req.DecodeAPIKey()
149+ if err != nil {
150+ c.server.logError(r, "Failed to decode api key", "error", err, "kind", req.Kind)
151+ jsonError(w, http.StatusBadRequest, err.Error())
152+ return
153+ }
154+
148− if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, req.APIKey); err != nil {
155+ if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, apiKey); err != nil {
149 c.handleError(w, r, err)
156 c.handleError(w, r, err)
150 return
157 return
151 }
158 }
internal/server/events_test.gonew file+46
@@ -0,0 +1,46 @@
1+package server
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/message"
7+ "github.com/charmbracelet/crush/internal/proto"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestMessageToProtoToolResult ensures that ToolResult metadata,
12+// data, and MIME type survive the conversion to proto. Without these
13+// fields the TUI cannot render rich tool output (e.g. syntax-
14+// highlighted code from view, diffs from edit, images, etc.) and
15+// falls back to the raw LLM-facing string.
16+func TestMessageToProtoToolResult(t *testing.T) {
17+ t.Parallel()
18+
19+ src := message.Message{
20+ ID: "m1",
21+ Role: message.Tool,
22+ Parts: []message.ContentPart{
23+ message.ToolResult{
24+ ToolCallID: "call-1",
25+ Name: "view",
26+ Content: "<file>\n 1| hi\n</file>",
27+ Data: "base64data",
28+ MIMEType: "image/png",
29+ Metadata: `{"file_path":"/tmp/x","content":"hi"}`,
30+ IsError: false,
31+ },
32+ },
33+ }
34+
35+ got := messageToProto(src)
36+ require.Len(t, got.Parts, 1)
37+ tr, ok := got.Parts[0].(proto.ToolResult)
38+ require.True(t, ok, "expected proto.ToolResult, got %T", got.Parts[0])
39+ require.Equal(t, "call-1", tr.ToolCallID)
40+ require.Equal(t, "view", tr.Name)
41+ require.Equal(t, "<file>\n 1| hi\n</file>", tr.Content)
42+ require.Equal(t, "base64data", tr.Data)
43+ require.Equal(t, "image/png", tr.MIMEType)
44+ require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
45+ require.False(t, tr.IsError)
46+}
internal/shell/expand.go−3
@@ -31,9 +31,6 @@ const maxInnerStderrBytes = 512
31// memory model regardless of test-level happens-before reasoning. The
31// memory model regardless of test-level happens-before reasoning. The
32// atomic load on the hot path is negligible against the cost of parsing
32// atomic load on the hot path is negligible against the cost of parsing
33// and running through mvdan.
33// and running through mvdan.
34−//
35−// See PLAN.md Phase 2 design decisions #11 and #12 for the full
36−// rationale.
37var NoUnset atomic.Bool
34var NoUnset atomic.Bool
38
35
39// ExpandValue expands shell-style substitutions in a single config value.
36// ExpandValue expands shell-style substitutions in a single config value.
internal/swagger/docs.go+131−3
@@ -1418,6 +1418,74 @@ const docTemplate = `{
1418 }
1418 }
1419 }
1419 }
1420 },
1420 },
1421+ "/workspaces/{id}/mcp/docker/disable": {
1422+ "post": {
1423+ "tags": [
1424+ "mcp"
1425+ ],
1426+ "summary": "Disable Docker MCP",
1427+ "parameters": [
1428+ {
1429+ "type": "string",
1430+ "description": "Workspace ID",
1431+ "name": "id",
1432+ "in": "path",
1433+ "required": true
1434+ }
1435+ ],
1436+ "responses": {
1437+ "200": {
1438+ "description": "OK"
1439+ },
1440+ "404": {
1441+ "description": "Not Found",
1442+ "schema": {
1443+ "$ref": "#/definitions/proto.Error"
1444+ }
1445+ },
1446+ "500": {
1447+ "description": "Internal Server Error",
1448+ "schema": {
1449+ "$ref": "#/definitions/proto.Error"
1450+ }
1451+ }
1452+ }
1453+ }
1454+ },
1455+ "/workspaces/{id}/mcp/docker/enable": {
1456+ "post": {
1457+ "tags": [
1458+ "mcp"
1459+ ],
1460+ "summary": "Enable Docker MCP",
1461+ "parameters": [
1462+ {
1463+ "type": "string",
1464+ "description": "Workspace ID",
1465+ "name": "id",
1466+ "in": "path",
1467+ "required": true
1468+ }
1469+ ],
1470+ "responses": {
1471+ "200": {
1472+ "description": "OK"
1473+ },
1474+ "404": {
1475+ "description": "Not Found",
1476+ "schema": {
1477+ "$ref": "#/definitions/proto.Error"
1478+ }
1479+ },
1480+ "500": {
1481+ "description": "Internal Server Error",
1482+ "schema": {
1483+ "$ref": "#/definitions/proto.Error"
1484+ }
1485+ }
1486+ }
1487+ }
1488+ },
1421 "/workspaces/{id}/mcp/get-prompt": {
1489 "/workspaces/{id}/mcp/get-prompt": {
1422 "post": {
1490 "post": {
1423 "consumes": [
1491 "consumes": [
@@ -2618,6 +2686,23 @@ const docTemplate = `{
2618 }
2686 }
2619 }
2687 }
2620 },
2688 },
2689+ "config.HookConfig": {
2690+ "type": "object",
2691+ "properties": {
2692+ "command": {
2693+ "description": "Shell command to execute.",
2694+ "type": "string"
2695+ },
2696+ "matcher": {
2697+ "description": "Regex pattern tested against the tool name. Empty means match all.",
2698+ "type": "string"
2699+ },
2700+ "timeout": {
2701+ "description": "Timeout in seconds. Default 30.",
2702+ "type": "integer"
2703+ }
2704+ }
2705+ },
2621 "config.LSPConfig": {
2706 "config.LSPConfig": {
2622 "type": "object",
2707 "type": "object",
2623 "properties": {
2708 "properties": {
@@ -2691,6 +2776,12 @@ const docTemplate = `{
2691 "type": "string"
2776 "type": "string"
2692 }
2777 }
2693 },
2778 },
2779+ "enabled_tools": {
2780+ "type": "array",
2781+ "items": {
2782+ "type": "string"
2783+ }
2784+ },
2694 "env": {
2785 "env": {
2695 "type": "object",
2786 "type": "object",
2696 "additionalProperties": {
2787 "additionalProperties": {
@@ -2698,7 +2789,7 @@ const docTemplate = `{
2698 }
2789 }
2699 },
2790 },
2700 "headers": {
2791 "headers": {
2701− "description": "TODO: maybe make it possible to get the value from the env",
2792+ "description": "Headers are HTTP headers for HTTP/SSE MCP servers. Values run\nthrough shell expansion at MCP startup, so $VAR and $(cmd)\nwork. A header whose value resolves to the empty string (unset\nbare $VAR under lenient nounset, $(echo), or literal \"\") is\nomitted from the outgoing request rather than sent as\n\"Header:\".",
2702 "type": "object",
2793 "type": "object",
2703 "additionalProperties": {
2794 "additionalProperties": {
2704 "type": "string"
2795 "type": "string"
@@ -2881,6 +2972,15 @@ const docTemplate = `{
2881 "$schema": {
2972 "$schema": {
2882 "type": "string"
2973 "type": "string"
2883 },
2974 },
2975+ "hooks": {
2976+ "type": "object",
2977+ "additionalProperties": {
2978+ "type": "array",
2979+ "items": {
2980+ "$ref": "#/definitions/config.HookConfig"
2981+ }
2982+ }
2983+ },
2884 "lsp": {
2984 "lsp": {
2885 "$ref": "#/definitions/config.LSPs"
2985 "$ref": "#/definitions/config.LSPs"
2886 },
2986 },
@@ -2939,7 +3039,7 @@ const docTemplate = `{
2939 }
3039 }
2940 },
3040 },
2941 "data_directory": {
3041 "data_directory": {
2942− "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
3042+ "description": "DataDirectory is where Crush keeps per-project state such as\nthe SQLite database and workspace overrides. Relative paths are\nresolved against the working directory; absolute paths are used\nverbatim. After defaulting the stored value is always absolute.",
2943 "type": "string"
3043 "type": "string"
2944 },
3044 },
2945 "debug": {
3045 "debug": {
@@ -2963,6 +3063,12 @@ const docTemplate = `{
2963 "disable_provider_auto_update": {
3063 "disable_provider_auto_update": {
2964 "type": "boolean"
3064 "type": "boolean"
2965 },
3065 },
3066+ "disabled_skills": {
3067+ "type": "array",
3068+ "items": {
3069+ "type": "string"
3070+ }
3071+ },
2966 "disabled_tools": {
3072 "disabled_tools": {
2967 "type": "array",
3073 "type": "array",
2968 "items": {
3074 "items": {
@@ -3035,6 +3141,17 @@ const docTemplate = `{
3035 "StateDisabled"
3141 "StateDisabled"
3036 ]
3142 ]
3037 },
3143 },
3144+ "proto.APIKeyKind": {
3145+ "type": "string",
3146+ "enum": [
3147+ "string",
3148+ "oauth"
3149+ ],
3150+ "x-enum-varnames": [
3151+ "APIKeyKindString",
3152+ "APIKeyKindOAuth"
3153+ ]
3154+ },
3038 "proto.AgentInfo": {
3155 "proto.AgentInfo": {
3039 "type": "object",
3156 "type": "object",
3040 "properties": {
3157 "properties": {
@@ -3155,7 +3272,15 @@ const docTemplate = `{
3155 "proto.ConfigProviderKeyRequest": {
3272 "proto.ConfigProviderKeyRequest": {
3156 "type": "object",
3273 "type": "object",
3157 "properties": {
3274 "properties": {
3158− "api_key": {},
3275+ "api_key": {
3276+ "type": "array",
3277+ "items": {
3278+ "type": "integer"
3279+ }
3280+ },
3281+ "kind": {
3282+ "$ref": "#/definitions/proto.APIKeyKind"
3283+ },
3159 "provider_id": {
3284 "provider_id": {
3160 "type": "string"
3285 "type": "string"
3161 },
3286 },
@@ -3497,6 +3622,9 @@ const docTemplate = `{
3497 "proto.VersionInfo": {
3622 "proto.VersionInfo": {
3498 "type": "object",
3623 "type": "object",
3499 "properties": {
3624 "properties": {
3625+ "build_id": {
3626+ "type": "string"
3627+ },
3500 "commit": {
3628 "commit": {
3501 "type": "string"
3629 "type": "string"
3502 },
3630 },
internal/swagger/swagger.json+131−3
@@ -1411,6 +1411,74 @@
1411 }
1411 }
1412 }
1412 }
1413 },
1413 },
1414+ "/workspaces/{id}/mcp/docker/disable": {
1415+ "post": {
1416+ "tags": [
1417+ "mcp"
1418+ ],
1419+ "summary": "Disable Docker MCP",
1420+ "parameters": [
1421+ {
1422+ "type": "string",
1423+ "description": "Workspace ID",
1424+ "name": "id",
1425+ "in": "path",
1426+ "required": true
1427+ }
1428+ ],
1429+ "responses": {
1430+ "200": {
1431+ "description": "OK"
1432+ },
1433+ "404": {
1434+ "description": "Not Found",
1435+ "schema": {
1436+ "$ref": "#/definitions/proto.Error"
1437+ }
1438+ },
1439+ "500": {
1440+ "description": "Internal Server Error",
1441+ "schema": {
1442+ "$ref": "#/definitions/proto.Error"
1443+ }
1444+ }
1445+ }
1446+ }
1447+ },
1448+ "/workspaces/{id}/mcp/docker/enable": {
1449+ "post": {
1450+ "tags": [
1451+ "mcp"
1452+ ],
1453+ "summary": "Enable Docker MCP",
1454+ "parameters": [
1455+ {
1456+ "type": "string",
1457+ "description": "Workspace ID",
1458+ "name": "id",
1459+ "in": "path",
1460+ "required": true
1461+ }
1462+ ],
1463+ "responses": {
1464+ "200": {
1465+ "description": "OK"
1466+ },
1467+ "404": {
1468+ "description": "Not Found",
1469+ "schema": {
1470+ "$ref": "#/definitions/proto.Error"
1471+ }
1472+ },
1473+ "500": {
1474+ "description": "Internal Server Error",
1475+ "schema": {
1476+ "$ref": "#/definitions/proto.Error"
1477+ }
1478+ }
1479+ }
1480+ }
1481+ },
1414 "/workspaces/{id}/mcp/get-prompt": {
1482 "/workspaces/{id}/mcp/get-prompt": {
1415 "post": {
1483 "post": {
1416 "consumes": [
1484 "consumes": [
@@ -2611,6 +2679,23 @@
2611 }
2679 }
2612 }
2680 }
2613 },
2681 },
2682+ "config.HookConfig": {
2683+ "type": "object",
2684+ "properties": {
2685+ "command": {
2686+ "description": "Shell command to execute.",
2687+ "type": "string"
2688+ },
2689+ "matcher": {
2690+ "description": "Regex pattern tested against the tool name. Empty means match all.",
2691+ "type": "string"
2692+ },
2693+ "timeout": {
2694+ "description": "Timeout in seconds. Default 30.",
2695+ "type": "integer"
2696+ }
2697+ }
2698+ },
2614 "config.LSPConfig": {
2699 "config.LSPConfig": {
2615 "type": "object",
2700 "type": "object",
2616 "properties": {
2701 "properties": {
@@ -2684,6 +2769,12 @@
2684 "type": "string"
2769 "type": "string"
2685 }
2770 }
2686 },
2771 },
2772+ "enabled_tools": {
2773+ "type": "array",
2774+ "items": {
2775+ "type": "string"
2776+ }
2777+ },
2687 "env": {
2778 "env": {
2688 "type": "object",
2779 "type": "object",
2689 "additionalProperties": {
2780 "additionalProperties": {
@@ -2691,7 +2782,7 @@
2691 }
2782 }
2692 },
2783 },
2693 "headers": {
2784 "headers": {
2694− "description": "TODO: maybe make it possible to get the value from the env",
2785+ "description": "Headers are HTTP headers for HTTP/SSE MCP servers. Values run\nthrough shell expansion at MCP startup, so $VAR and $(cmd)\nwork. A header whose value resolves to the empty string (unset\nbare $VAR under lenient nounset, $(echo), or literal \"\") is\nomitted from the outgoing request rather than sent as\n\"Header:\".",
2695 "type": "object",
2786 "type": "object",
2696 "additionalProperties": {
2787 "additionalProperties": {
2697 "type": "string"
2788 "type": "string"
@@ -2874,6 +2965,15 @@
2874 "$schema": {
2965 "$schema": {
2875 "type": "string"
2966 "type": "string"
2876 },
2967 },
2968+ "hooks": {
2969+ "type": "object",
2970+ "additionalProperties": {
2971+ "type": "array",
2972+ "items": {
2973+ "$ref": "#/definitions/config.HookConfig"
2974+ }
2975+ }
2976+ },
2877 "lsp": {
2977 "lsp": {
2878 "$ref": "#/definitions/config.LSPs"
2978 "$ref": "#/definitions/config.LSPs"
2879 },
2979 },
@@ -2932,7 +3032,7 @@
2932 }
3032 }
2933 },
3033 },
2934 "data_directory": {
3034 "data_directory": {
2935− "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
3035+ "description": "DataDirectory is where Crush keeps per-project state such as\nthe SQLite database and workspace overrides. Relative paths are\nresolved against the working directory; absolute paths are used\nverbatim. After defaulting the stored value is always absolute.",
2936 "type": "string"
3036 "type": "string"
2937 },
3037 },
2938 "debug": {
3038 "debug": {
@@ -2956,6 +3056,12 @@
2956 "disable_provider_auto_update": {
3056 "disable_provider_auto_update": {
2957 "type": "boolean"
3057 "type": "boolean"
2958 },
3058 },
3059+ "disabled_skills": {
3060+ "type": "array",
3061+ "items": {
3062+ "type": "string"
3063+ }
3064+ },
2959 "disabled_tools": {
3065 "disabled_tools": {
2960 "type": "array",
3066 "type": "array",
2961 "items": {
3067 "items": {
@@ -3028,6 +3134,17 @@
3028 "StateDisabled"
3134 "StateDisabled"
3029 ]
3135 ]
3030 },
3136 },
3137+ "proto.APIKeyKind": {
3138+ "type": "string",
3139+ "enum": [
3140+ "string",
3141+ "oauth"
3142+ ],
3143+ "x-enum-varnames": [
3144+ "APIKeyKindString",
3145+ "APIKeyKindOAuth"
3146+ ]
3147+ },
3031 "proto.AgentInfo": {
3148 "proto.AgentInfo": {
3032 "type": "object",
3149 "type": "object",
3033 "properties": {
3150 "properties": {
@@ -3148,7 +3265,15 @@
3148 "proto.ConfigProviderKeyRequest": {
3265 "proto.ConfigProviderKeyRequest": {
3149 "type": "object",
3266 "type": "object",
3150 "properties": {
3267 "properties": {
3151− "api_key": {},
3268+ "api_key": {
3269+ "type": "array",
3270+ "items": {
3271+ "type": "integer"
3272+ }
3273+ },
3274+ "kind": {
3275+ "$ref": "#/definitions/proto.APIKeyKind"
3276+ },
3152 "provider_id": {
3277 "provider_id": {
3153 "type": "string"
3278 "type": "string"
3154 },
3279 },
@@ -3490,6 +3615,9 @@
3490 "proto.VersionInfo": {
3615 "proto.VersionInfo": {
3491 "type": "object",
3616 "type": "object",
3492 "properties": {
3617 "properties": {
3618+ "build_id": {
3619+ "type": "string"
3620+ },
3493 "commit": {
3621 "commit": {
3494 "type": "string"
3622 "type": "string"
3495 },
3623 },
internal/swagger/swagger.yaml+98−6
@@ -63,6 +63,19 @@ definitions:
63 max_items:
63 max_items:
64 type: integer
64 type: integer
65 type: object
65 type: object
66+ config.HookConfig:
67+ properties:
68+ command:
69+ description: Shell command to execute.
70+ type: string
71+ matcher:
72+ description: Regex pattern tested against the tool name. Empty means match
73+ all.
74+ type: string
75+ timeout:
76+ description: Timeout in seconds. Default 30.
77+ type: integer
78+ type: object
66 config.LSPConfig:
79 config.LSPConfig:
67 properties:
80 properties:
68 args:
81 args:
@@ -112,6 +125,10 @@ definitions:
112 items:
125 items:
113 type: string
126 type: string
114 type: array
127 type: array
128+ enabled_tools:
129+ items:
130+ type: string
131+ type: array
115 env:
132 env:
116 additionalProperties:
133 additionalProperties:
117 type: string
134 type: string
@@ -119,7 +136,13 @@ definitions:
119 headers:
136 headers:
120 additionalProperties:
137 additionalProperties:
121 type: string
138 type: string
122− description: 'TODO: maybe make it possible to get the value from the env'
139+ description: |-
140+ Headers are HTTP headers for HTTP/SSE MCP servers. Values run
141+ through shell expansion at MCP startup, so $VAR and $(cmd)
142+ work. A header whose value resolves to the empty string (unset
143+ bare $VAR under lenient nounset, $(echo), or literal "") is
144+ omitted from the outgoing request rather than sent as
145+ "Header:".
123 type: object
146 type: object
124 timeout:
147 timeout:
125 type: integer
148 type: integer
@@ -249,6 +272,12 @@ definitions:
249 properties:
272 properties:
250 $schema:
273 $schema:
251 type: string
274 type: string
275+ hooks:
276+ additionalProperties:
277+ items:
278+ $ref: '#/definitions/config.HookConfig'
279+ type: array
280+ type: object
252 lsp:
281 lsp:
253 $ref: '#/definitions/config.LSPs'
282 $ref: '#/definitions/config.LSPs'
254 mcp:
283 mcp:
@@ -288,10 +317,10 @@ definitions:
288 type: array
317 type: array
289 data_directory:
318 data_directory:
290 description: |-
319 description: |-
291− DataDirectory is where Crush keeps per-project state such as the SQLite
320+ DataDirectory is where Crush keeps per-project state such as
292− database and workspace overrides. Relative paths are resolved against
321+ the SQLite database and workspace overrides. Relative paths are
293− the working directory; absolute paths are used verbatim. After
322+ resolved against the working directory; absolute paths are used
294− defaulting the stored value is always absolute.
323+ verbatim. After defaulting the stored value is always absolute.
295 type: string
324 type: string
296 debug:
325 debug:
297 type: boolean
326 type: boolean
@@ -307,6 +336,10 @@ definitions:
307 type: boolean
336 type: boolean
308 disable_provider_auto_update:
337 disable_provider_auto_update:
309 type: boolean
338 type: boolean
339+ disabled_skills:
340+ items:
341+ type: string
342+ type: array
310 disabled_tools:
343 disabled_tools:
311 items:
344 items:
312 type: string
345 type: string
@@ -358,6 +391,14 @@ definitions:
358 - StateError
391 - StateError
359 - StateStopped
392 - StateStopped
360 - StateDisabled
393 - StateDisabled
394+ proto.APIKeyKind:
395+ enum:
396+ - string
397+ - oauth
398+ type: string
399+ x-enum-varnames:
400+ - APIKeyKindString
401+ - APIKeyKindOAuth
361 proto.AgentInfo:
402 proto.AgentInfo:
362 properties:
403 properties:
363 is_busy:
404 is_busy:
@@ -436,7 +477,12 @@ definitions:
436 type: object
477 type: object
437 proto.ConfigProviderKeyRequest:
478 proto.ConfigProviderKeyRequest:
438 properties:
479 properties:
439− api_key: {}
480+ api_key:
481+ items:
482+ type: integer
483+ type: array
484+ kind:
485+ $ref: '#/definitions/proto.APIKeyKind'
440 provider_id:
486 provider_id:
441 type: string
487 type: string
442 scope:
488 scope:
@@ -664,6 +710,8 @@ definitions:
664 type: object
710 type: object
665 proto.VersionInfo:
711 proto.VersionInfo:
666 properties:
712 properties:
713+ build_id:
714+ type: string
667 commit:
715 commit:
668 type: string
716 type: string
669 go_version:
717 go_version:
@@ -1639,6 +1687,50 @@ paths:
1639 summary: Stop all LSP servers
1687 summary: Stop all LSP servers
1640 tags:
1688 tags:
1641 - lsp
1689 - lsp
1690+ /workspaces/{id}/mcp/docker/disable:
1691+ post:
1692+ parameters:
1693+ - description: Workspace ID
1694+ in: path
1695+ name: id
1696+ required: true
1697+ type: string
1698+ responses:
1699+ "200":
1700+ description: OK
1701+ "404":
1702+ description: Not Found
1703+ schema:
1704+ $ref: '#/definitions/proto.Error'
1705+ "500":
1706+ description: Internal Server Error
1707+ schema:
1708+ $ref: '#/definitions/proto.Error'
1709+ summary: Disable Docker MCP
1710+ tags:
1711+ - mcp
1712+ /workspaces/{id}/mcp/docker/enable:
1713+ post:
1714+ parameters:
1715+ - description: Workspace ID
1716+ in: path
1717+ name: id
1718+ required: true
1719+ type: string
1720+ responses:
1721+ "200":
1722+ description: OK
1723+ "404":
1724+ description: Not Found
1725+ schema:
1726+ $ref: '#/definitions/proto.Error'
1727+ "500":
1728+ description: Internal Server Error
1729+ schema:
1730+ $ref: '#/definitions/proto.Error'
1731+ summary: Enable Docker MCP
1732+ tags:
1733+ - mcp
1642 /workspaces/{id}/mcp/get-prompt:
1734 /workspaces/{id}/mcp/get-prompt:
1643 post:
1735 post:
1644 consumes:
1736 consumes:
internal/workspace/client_workspace_test.gonew file+46
@@ -0,0 +1,46 @@
1+package workspace
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/message"
7+ "github.com/charmbracelet/crush/internal/proto"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestProtoToMessageToolResult ensures that ToolResult metadata,
12+// data, and MIME type survive the conversion from proto on the
13+// client. Without these fields the TUI cannot render rich tool
14+// output (e.g. syntax-highlighted code from view, diffs from edit,
15+// images, etc.) and falls back to the raw LLM-facing string.
16+func TestProtoToMessageToolResult(t *testing.T) {
17+ t.Parallel()
18+
19+ src := proto.Message{
20+ ID: "m1",
21+ Role: proto.Tool,
22+ Parts: []proto.ContentPart{
23+ proto.ToolResult{
24+ ToolCallID: "call-1",
25+ Name: "view",
26+ Content: "<file>\n 1| hi\n</file>",
27+ Data: "base64data",
28+ MIMEType: "image/png",
29+ Metadata: `{"file_path":"/tmp/x","content":"hi"}`,
30+ IsError: false,
31+ },
32+ },
33+ }
34+
35+ got := protoToMessage(src)
36+ require.Len(t, got.Parts, 1)
37+ tr, ok := got.Parts[0].(message.ToolResult)
38+ require.True(t, ok, "expected message.ToolResult, got %T", got.Parts[0])
39+ require.Equal(t, "call-1", tr.ToolCallID)
40+ require.Equal(t, "view", tr.Name)
41+ require.Equal(t, "<file>\n 1| hi\n</file>", tr.Content)
42+ require.Equal(t, "base64data", tr.Data)
43+ require.Equal(t, "image/png", tr.MIMEType)
44+ require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
45+ require.False(t, tr.IsError)
46+}