diff --git a/CLAUDE.md b/CLAUDE.md index 8c8063a..4fec922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ Services initialize in `cmd/daemon/main.go`: check enabled flag → create → s - Daemon socket: `/tmp/shelltime.sock` (configurable via `socketPath`) - AICodeOtel gRPC port: configurable via `aiCodeOtel.grpcPort` (default: 54027) - Outbound proxy: `proxy.url` / `proxy.noProxy` (http, https, socks5, socks5h). Applied once at startup via `model.ConfigureProxy`; every outbound HTTP client must use `model.NewHTTPClient` or `model.HTTPTransport()` so the proxy applies +- Token `proxy-injected` (`model.ProxyInjectedToken`): a Claude Code cloud environment keeps the real token as a network secret, and its agent proxy adds `Authorization: CLI ` to requests for `api.shelltime.xyz`. Requests to the API then carry no `Authorization` header of their own (`setCLIAuthorization`); set it through that helper, never by hand. `cloud.bash` in the installation repo writes it ## Commit Rules diff --git a/commands/doctor_checks.go b/commands/doctor_checks.go index 0a4d473..c381262 100644 --- a/commands/doctor_checks.go +++ b/commands/doctor_checks.go @@ -443,7 +443,14 @@ func doctorCheckAccount(env *doctorEnv) []doctorResult { Fix: "Run `shelltime auth` to sign in.", }} } - results := []doctorResult{{ID: "auth.token", Status: doctorOK, Message: fmt.Sprintf("An API token is configured (%s).", maskDoctorToken(cfg.Token))}} + proxyInjected := cfg.Token == model.ProxyInjectedToken + tokenResult := doctorResult{ID: "auth.token", Status: doctorOK, Message: fmt.Sprintf("An API token is configured (%s).", maskDoctorToken(cfg.Token))} + reauthFix := "Run `shelltime auth` to sign in again." + if proxyInjected { + tokenResult.Message = "The API token is a network secret of this Claude Code cloud environment, added to requests by its agent proxy." + reauthFix = "In the cloud environment's settings, add your ShellTime token as a network secret for api.shelltime.xyz (header Authorization, prefix CLI), or replace a revoked one." + } + results := []doctorResult{tokenResult} switch { case env.offline: @@ -466,11 +473,16 @@ func doctorCheckAccount(env *doctorEnv) []doctorResult { case errors.As(err, &statusErr) && (statusErr.StatusCode == 401 || statusErr.StatusCode == 403): result.Status = doctorFail result.Message = fmt.Sprintf("The server rejected your token (HTTP %d): it's expired, revoked or disabled, so nothing is being uploaded.", statusErr.StatusCode) - result.Fix = "Run `shelltime auth` to sign in again." + result.Fix = reauthFix + case proxyInjected && errors.As(err, &statusErr) && statusErr.StatusCode == 404: + // A request without a token reads as an unknown user + result.Status = doctorFail + result.Message = "No token reached the server: the cloud environment has no network secret for api.shelltime.xyz, or its agent proxy didn't send it, so nothing is being uploaded." + result.Fix = reauthFix case err == nil: result.Status = doctorFail result.Message = "The server didn't recognise your token, so nothing is being uploaded." - result.Fix = "Run `shelltime auth` to sign in again." + result.Fix = reauthFix default: result.Status = doctorWarn result.Message = fmt.Sprintf("Couldn't reach %s to verify your token: %v", cfg.APIEndpoint, err) diff --git a/commands/doctor_test.go b/commands/doctor_test.go index d207b82..f568cbc 100644 --- a/commands/doctor_test.go +++ b/commands/doctor_test.go @@ -341,6 +341,47 @@ func TestDoctorCheckAccount(t *testing.T) { assert.Equal(t, doctorWarn, findDoctorResult(t, doctorCheckAccount(env), "auth.server").Status, "unreachable server") } +// In a Claude Code cloud environment the token is a network secret: doctor says so, checks it +// through the request the agent proxy completes, and points a rejection at the secret. +func TestDoctorCheckAccount_ProxyInjectedToken(t *testing.T) { + setupDoctorTest(t) + + var status int + var body string + authHeaders := []string{"unset"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeaders = r.Header.Values("Authorization") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + env := newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.Token = model.ProxyInjectedToken + env.cfg.APIEndpoint = srv.URL + + status, body = http.StatusOK, `{"data":{"fetchUser":{"id":1,"login":"annatar"}}}` + results := doctorCheckAccount(env) + token := findDoctorResult(t, results, "auth.token") + assert.Equal(t, doctorOK, token.Status) + assert.Contains(t, token.Message, "network secret") + assert.Equal(t, doctorOK, findDoctorResult(t, results, "auth.server").Status) + assert.Empty(t, authHeaders, "the agent proxy adds the header") + + status, body = http.StatusUnauthorized, `{"code":401,"error":"unauthorized"}` + server := findDoctorResult(t, doctorCheckAccount(env), "auth.server") + assert.Equal(t, doctorFail, server.Status) + assert.Contains(t, server.Fix, "network secret") + assert.NotContains(t, server.Fix, "shelltime auth") + + // No secret: the request arrives without a token and the user isn't found + status, body = http.StatusNotFound, `{"errors":[{"message":"[404]: ent: user not found"}],"data":null}` + server = findDoctorResult(t, doctorCheckAccount(env), "auth.server") + assert.Equal(t, doctorFail, server.Status) + assert.Contains(t, server.Message, "No token reached the server") + assert.Contains(t, server.Fix, "network secret") +} + // --- Privacy ------------------------------------------------------------------ func TestDoctorCheckPrivacy(t *testing.T) { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 6217ff1..24cf264 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -90,6 +90,8 @@ apiEndpoint: "https://api.shelltime.xyz" webEndpoint: "https://shelltime.xyz" ``` +In a Claude Code cloud environment set up with [cloud.bash](https://github.com/shelltime/installation/blob/main/cloud.bash), the token is `proxy-injected`: the environment keeps your real token as a network secret, and its agent proxy adds it to every request for `api.shelltime.xyz`, so the CLI sends none itself. See [Track your Claude Code cloud sessions](https://shelltime.xyz/blogs/track-claude-code-cloud-sessions). + ### Sync Settings | Option | Type | Default | Description | diff --git a/model/ai_service.go b/model/ai_service.go index 68f8016..9acd6c7 100644 --- a/model/ai_service.go +++ b/model/ai_service.go @@ -51,7 +51,7 @@ func (s *sseAIService) QueryCommandStream( } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Authorization", "CLI "+endpoint.Token) + setCLIAuthorization(req, endpoint.Token) client := &http.Client{Timeout: 2 * time.Minute, Transport: HTTPTransport()} resp, err := client.Do(req) diff --git a/model/aicode_cloud_session.go b/model/aicode_cloud_session.go index 81f70eb..718a76c 100644 --- a/model/aicode_cloud_session.go +++ b/model/aicode_cloud_session.go @@ -1,6 +1,9 @@ package model -import "strings" +import ( + "net/http" + "strings" +) // AICodeCloudSessionIDAttribute is the standard attribute Claude Code puts on every event and // metric of a cloud session (claude.ai/code): its CLAUDE_CODE_REMOTE_SESSION_ID, like cse_01AbC. @@ -17,3 +20,18 @@ func CloudSessionIDFromEnv(getenv func(string) string) string { } return strings.TrimSpace(getenv("CLAUDE_CODE_REMOTE_SESSION_ID")) } + +// ProxyInjectedToken is the token a Claude Code cloud environment's sessions sign in with when +// the real one is a network secret: the agent proxy adds `Authorization: CLI ` to every +// request for api.shelltime.xyz after it leaves the VM, so the token is never on disk or in the +// environment. Cloud sessions read GITHUB_TOKEN as the same placeholder for the GitHub proxy. +const ProxyInjectedToken = "proxy-injected" + +// setCLIAuthorization authenticates a request to the ShellTime API. With ProxyInjectedToken it +// sends no Authorization header, so the one the proxy adds is the only one the server reads. +func setCLIAuthorization(req *http.Request, token string) { + if token == ProxyInjectedToken { + return + } + req.Header.Set("Authorization", "CLI "+token) +} diff --git a/model/aicode_cloud_session_test.go b/model/aicode_cloud_session_test.go index 2691a7b..c76adcc 100644 --- a/model/aicode_cloud_session_test.go +++ b/model/aicode_cloud_session_test.go @@ -1,6 +1,9 @@ package model import ( + "context" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -25,3 +28,44 @@ func TestCloudSessionIDFromEnv(t *testing.T) { }) } } + +// With the network secret's placeholder, requests to the ShellTime API carry no Authorization +// header for the agent proxy to collide with; any other token is sent as before. +func TestSetCLIAuthorization(t *testing.T) { + tests := []struct { + token string + wantAuth []string + }{ + {token: ProxyInjectedToken, wantAuth: nil}, + {token: "st_v1_abc", wantAuth: []string{"CLI st_v1_abc"}}, + } + for _, tt := range tests { + t.Run(tt.token, func(t *testing.T) { + var gotJSON, gotStream []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/ai/command-suggest" { + gotStream = r.Header.Values("Authorization") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data:[DONE]\n")) + return + } + gotJSON = r.Header.Values("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + endpoint := Endpoint{APIEndpoint: srv.URL, Token: tt.token} + + require.NoError(t, SendHTTPRequestJSON(HTTPRequestOptions[map[string]string, any]{ + Context: context.Background(), + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/cc/otel", + Payload: map[string]string{}, + })) + require.Equal(t, tt.wantAuth, gotJSON) + + require.NoError(t, NewAIService().QueryCommandStream(context.Background(), CommandSuggestVariables{}, endpoint, func(string) {})) + require.Equal(t, tt.wantAuth, gotStream) + }) + } +} diff --git a/model/api.base.go b/model/api.base.go index 923d0ea..48a6453 100644 --- a/model/api.base.go +++ b/model/api.base.go @@ -65,7 +65,7 @@ func SendHTTPRequestJSON[T any, R any](opts HTTPRequestOptions[T, R]) error { req.Header.Set("Content-Type", contentType) req.Header.Set("User-Agent", fmt.Sprintf("shelltimeCLI@%s", commitID)) - req.Header.Set("Authorization", "CLI "+opts.Endpoint.Token) + setCLIAuthorization(req, opts.Endpoint.Token) slog.Debug("http request", slog.String("url", req.URL.String()))