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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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

Expand Down
18 changes: 15 additions & 3 deletions commands/doctor_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions commands/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion model/ai_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion model/aicode_cloud_session.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 <token>` 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)
}
44 changes: 44 additions & 0 deletions model/aicode_cloud_session_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package model

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -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)
})
}
}
2 changes: 1 addition & 1 deletion model/api.base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down
Loading