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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions cli/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ Run 'cix config keys' to list every settable key with its description,
default, and env-var override. Beyond those schema keys, three patterns
manage the multi-server layout:

server.<name>.url URL of a named server (creates the entry if absent)
server.<name>.key API key of a named server
default_server which server is used when --server is omitted
api.url / api.key legacy aliases — operate on the default server
server.<name>.url URL of a named server (creates the entry if absent)
server.<name>.key API key of a named server
server.<name>.header.<Name> custom HTTP header sent on every request
default_server which server is used when --server is omitted
api.url / api.key legacy aliases — operate on the default server

Custom headers let the CLI pass an authenticating reverse proxy in front of
cix (e.g. a Cloudflare Access service token). Values support ${ENV} expansion
at request time, so secrets stay out of the config file.

List-valued keys (e.g. watcher.exclude) use comma-separated input with
REPLACE semantics: 'cix config set watcher.exclude "node_modules,vendor"'
Expand All @@ -49,6 +54,10 @@ Examples:
cix config set server.corporate.key cix_abc123...
cix config set default_server corporate

# custom headers (e.g. Cloudflare Access service token):
cix config set server.corporate.header.CF-Access-Client-Id "<id>.access"
cix config set server.corporate.header.CF-Access-Client-Secret '${CIX_CF_ACCESS_SECRET}'

cix config set api.url http://localhost:21847 # legacy alias
cix config set api.key cix_abc123... # legacy alias

Expand All @@ -65,12 +74,14 @@ var configUnsetCmd = &cobra.Command{
Long: `Remove configuration entries.

Supported keys:
server.<name> - remove the named server entirely
server.<name>.key - clear the named server's API key
server.<name> - remove the named server entirely
server.<name>.key - clear the named server's API key
server.<name>.header.<Name> - remove a custom HTTP header

Examples:
cix config unset server.corporate
cix config unset server.corporate.key`,
cix config unset server.corporate.key
cix config unset server.corporate.header.CF-Access-Client-Id`,
Args: cobra.ExactArgs(1),
RunE: runConfigUnset,
}
Expand Down Expand Up @@ -159,7 +170,13 @@ func renderServersBlock(w io.Writer, cfg *config.Config) {
if s.Name == cfg.DefaultServer {
marker = "* "
}
fmt.Fprintf(w, "%s%-16s url=%s key=%s\n", marker, s.Name, s.URL, keyStatus)
fmt.Fprintf(w, "%s%-16s url=%s key=%s", marker, s.Name, s.URL, keyStatus)
// Surface custom headers by COUNT only — values may be secrets and must
// never be printed. Omitted entirely when none are set.
if n := len(s.Headers); n > 0 {
fmt.Fprintf(w, " headers=%d", n)
}
fmt.Fprintln(w)
}
}

Expand Down Expand Up @@ -242,6 +259,16 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
fmt.Printf("✓ Set %s (server %q)\n", key, name)
return nil
case strings.HasPrefix(key, "server."):
// Header form: server.<name>.header.<HeaderName> (HeaderName may itself
// contain dots, so it is everything after the 3rd segment).
if name, headerName, ok := parseServerHeaderKey(key); ok {
if err := config.SetServerHeader(name, headerName, value); err != nil {
return err
}
// Never echo the value — header values may be secrets.
fmt.Printf("✓ Set server.%s.header.%s\n", name, headerName)
return nil
}
name, field, perr := parseServerKey(key)
if perr != nil {
return perr
Expand Down Expand Up @@ -282,6 +309,15 @@ func runConfigUnset(cmd *cobra.Command, args []string) error {
return fmt.Errorf("unknown unset key: %s (supported: server.<name>, server.<name>.key)", key)
}

// Header form: server.<name>.header.<HeaderName>.
if name, headerName, ok := parseServerHeaderKey(key); ok {
if err := config.UnsetServerHeader(name, headerName); err != nil {
return err
}
fmt.Printf("✓ Removed header %q for server %q\n", headerName, name)
return nil
}

rest := strings.TrimPrefix(key, "server.")
switch {
case strings.HasSuffix(rest, ".key"):
Expand Down Expand Up @@ -319,6 +355,18 @@ func defaultServerName(cfg *config.Config) string {
return config.DefaultServerName
}

// parseServerHeaderKey recognises the `server.<name>.header.<HeaderName>` form
// and splits it into the server name and header name. HeaderName is everything
// after the literal `header.` segment, so it may itself contain dots. Returns
// ok=false for any other shape (so callers fall through to parseServerKey).
func parseServerHeaderKey(key string) (name, headerName string, ok bool) {
parts := strings.SplitN(key, ".", 4)
if len(parts) != 4 || parts[0] != "server" || parts[1] == "" || parts[2] != "header" || parts[3] == "" {
return "", "", false
}
return parts[1], parts[3], true
}

// parseServerKey splits a `server.<name>.<field>` config key into its name and
// field (url|key), validating the shape.
func parseServerKey(key string) (name, field string, err error) {
Expand Down
116 changes: 116 additions & 0 deletions cli/cmd/headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cmd

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/anthropics/code-index/cli/internal/config"
)

// TestGetClient_ExpandsHeaderEnvVars is the end-to-end proof of issue #59:
// a configured header with a ${VAR} placeholder is expanded at request time
// and reaches the wire, while the on-disk config keeps the placeholder (no
// plaintext secret persisted).
func TestGetClient_ExpandsHeaderEnvVars(t *testing.T) {
isolateConfig(t)

var got http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

if err := config.SetServerURL(config.DefaultServerName, srv.URL); err != nil {
t.Fatal(err)
}
if err := config.SetServerKey(config.DefaultServerName, "k"); err != nil {
t.Fatal(err)
}
if err := config.SetServerHeader(config.DefaultServerName, "CF-Access-Client-Secret", "${CIX_TEST_SECRET}"); err != nil {
t.Fatal(err)
}
t.Setenv("CIX_TEST_SECRET", "expanded-secret-123")
withFlags(t, "", "", "")

c, err := getClient()
if err != nil {
t.Fatalf("getClient: %v", err)
}
if err := c.Health(); err != nil {
t.Fatalf("Health: %v", err)
}

if got.Get("CF-Access-Client-Secret") != "expanded-secret-123" {
t.Errorf("header on wire = %q, want expanded-secret-123", got.Get("CF-Access-Client-Secret"))
}

// The config file must still hold the placeholder, not the secret.
raw, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".cix", "config.yaml"))
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(raw), "expanded-secret-123") {
t.Errorf("secret leaked into config file:\n%s", raw)
}
if !strings.Contains(string(raw), "${CIX_TEST_SECRET}") {
t.Errorf("config should keep the ${CIX_TEST_SECRET} placeholder:\n%s", raw)
}
}

// TestGetClient_UnsetHeaderEnvVarErrors ensures a header referencing an unset
// env var fails getClient loudly (naming the var) instead of silently sending
// an empty header that would bounce at the proxy — finding #1.
func TestGetClient_UnsetHeaderEnvVarErrors(t *testing.T) {
isolateConfig(t)
if err := config.SetServerURL(config.DefaultServerName, "http://localhost:21847"); err != nil {
t.Fatal(err)
}
if err := config.SetServerKey(config.DefaultServerName, "k"); err != nil {
t.Fatal(err)
}
if err := config.SetServerHeader(config.DefaultServerName, "CF-Access-Client-Secret", "${CIX_DEFINITELY_UNSET_VAR}"); err != nil {
t.Fatal(err)
}
// Deliberately do NOT set CIX_DEFINITELY_UNSET_VAR.
withFlags(t, "", "", "")

_, err := getClient()
if err == nil {
t.Fatal("expected getClient to fail on an unset header env var")
}
if !strings.Contains(err.Error(), "CIX_DEFINITELY_UNSET_VAR") {
t.Errorf("error should name the missing variable, got %v", err)
}
}

// TestGetClient_InvalidHeaderErrors ensures a malformed header (after env
// expansion) fails getClient loudly and never echoes the value.
func TestGetClient_InvalidHeaderErrors(t *testing.T) {
isolateConfig(t)
if err := config.SetServerURL(config.DefaultServerName, "http://localhost:21847"); err != nil {
t.Fatal(err)
}
if err := config.SetServerKey(config.DefaultServerName, "k"); err != nil {
t.Fatal(err)
}
// Header value resolves to one containing CRLF via the env var (the config
// setter itself would reject a literal CRLF, but expansion happens later).
if err := config.SetServerHeader(config.DefaultServerName, "X-Bad", "${CIX_TEST_BAD}"); err != nil {
t.Fatal(err)
}
t.Setenv("CIX_TEST_BAD", "line1\r\nInjected: 1")
withFlags(t, "", "", "")

_, err := getClient()
if err == nil {
t.Fatal("expected getClient to reject a CRLF-injected header value")
}
if strings.Contains(err.Error(), "Injected") {
t.Errorf("error must not echo the header value: %v", err)
}
}
22 changes: 22 additions & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,28 @@ func getClient() (*client.Client, error) {
}

c := client.New(url, key)

// Custom headers: ${ENV}-expand each value into a local copy (never
// written back to disk, like the url/key overrides above) so secrets can
// stay in the environment rather than in config.yaml. Expansion is strict —
// a referenced-but-unset variable is an error, not a silent empty header —
// and validates after expansion. All failures name the header/variable but
// never echo the resolved value.
if len(srv.Headers) > 0 {
expanded := make(map[string]string, len(srv.Headers))
for name, raw := range srv.Headers {
val, err := config.ExpandEnvHeaderValue(raw)
if err != nil {
return nil, fmt.Errorf("custom header %q for server %q: %w", name, srv.Name, err)
}
if err := config.ValidateHeader(name, val); err != nil {
return nil, fmt.Errorf("invalid custom header %q for server %q: %w", name, srv.Name, err)
}
expanded[name] = val
}
c.SetCustomHeaders(expanded)
}

if cfg.Indexing.StreamingIdleTimeoutSec > 0 {
c.SetStreamingIdleTimeout(time.Duration(cfg.Indexing.StreamingIdleTimeoutSec) * time.Second)
}
Expand Down
36 changes: 34 additions & 2 deletions cli/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ type Client struct {
apiKey string
httpClient *http.Client

// customHeaders are user-configured headers attached to every outbound
// request (in addition to the cix Bearer) so the client can satisfy an
// authenticating reverse proxy in front of cix. Values are already
// ${ENV}-expanded and validated by the caller (getClient). Never logged.
customHeaders map[string]string

// streamingClient is used for endpoints that return chunked NDJSON
// (currently only POST /index/files when Accept advertises x-ndjson).
// Timeout is 0 because the natural duration of an indexing batch is
Expand Down Expand Up @@ -50,6 +56,22 @@ func New(baseURL, apiKey string) *Client {
}
}

// SetCustomHeaders configures extra headers attached to every request. The
// map is used as-is (values must already be expanded/validated by the caller).
// Passing nil or an empty map is a no-op — current behavior is preserved.
func (c *Client) SetCustomHeaders(h map[string]string) {
c.customHeaders = h
}

// applyCustomHeaders attaches the configured custom headers to req. It is
// always called BEFORE the cix-managed headers (Authorization, Content-Type,
// Accept) are set, so a stray config value can never clobber authentication.
func (c *Client) applyCustomHeaders(req *http.Request) {
for k, v := range c.customHeaders {
req.Header.Set(k, v)
}
}

// SetStreamingIdleTimeout overrides the silence threshold for streaming
// endpoints. Pass 0 to disable the watchdog entirely (not recommended).
func (c *Client) SetStreamingIdleTimeout(d time.Duration) {
Expand Down Expand Up @@ -77,6 +99,8 @@ func (c *Client) do(method, path string, body interface{}) (*http.Response, erro
return nil, fmt.Errorf("create request: %w", err)
}

// Custom headers first, then cix-managed headers — so the latter win.
c.applyCustomHeaders(req)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
Expand Down Expand Up @@ -192,9 +216,17 @@ func parseResponse(resp *http.Response, v interface{}) error {
return nil
}

// Health checks if the API server is running
// Health checks if the API server is running. Although /health is public on
// cix, the request must still carry any custom headers — behind an
// authenticating reverse proxy the probe would otherwise be bounced (302/403)
// at the edge before reaching cix.
func (c *Client) Health() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
c.applyCustomHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
Expand Down
Loading
Loading