From 5ba232715cef9eb73ffd8cd7bb6155ee8b619050 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:26:29 +0000 Subject: [PATCH 1/2] fix(commands): keep the tests from tracking commands through a real daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shelltime track` hands each event to the daemon on /tmp/shelltime.sock before reading any config, and the track tests ran it with that path hard-coded. On a machine whose daemon is running, a Claude Code cloud session's or your own, their `cmd1 0`…`cmd1 15` commands went to that daemon's account and created a host for the machine. The fast path now reads its socket from `trackSocketPath`, which the commands TestMain points at a path that doesn't exist. It also clears CLAUDE_CODE_REMOTE*, like the daemon suite, so the tests behave the same inside a cloud session as on CI. The skip for a present default socket is gone: those tests run everywhere now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Um2DevMgViC7Jtb8jaXFF3 --- CLAUDE.md | 2 ++ commands/main_test.go | 20 ++++++++++++++++++++ commands/track.go | 6 +++++- commands/track_cov_test.go | 10 +++------- 4 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 commands/main_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 10aab71..2bd48ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,8 @@ go test -run TestHandlerName ./daemon/ Tests use **testify** (assertions + suites). Suite-based tests use `suite.Suite` with `SetupTest`/`TearDownTest` lifecycle hooks (see `daemon/cc_info_handler_test.go` for example). Simple functions use table-driven tests. +Tests must never reach the daemon running on the machine (yours, or a Claude Code cloud session's): it uploads to a real account. `commands/main_test.go` points `track`'s fast-path socket (`trackSocketPath`) at a path that doesn't exist, and every other test uses a temp `socketPath`. The `commands` and `daemon` `TestMain`s clear `CLAUDE_CODE_REMOTE*`, so tests behave the same inside a cloud session as on CI. + ### Performance (`shelltime track` latency) `perf/` is a separate Go module (root `./...` skips it). It benchmarks the real binary the way the shell hooks run it: daemon and direct paths, pre and post, plus a sync. See `perf/README.md`. ```bash diff --git a/commands/main_test.go b/commands/main_test.go new file mode 100644 index 0000000..b21ee14 --- /dev/null +++ b/commands/main_test.go @@ -0,0 +1,20 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// TestMain keeps the suite away from the ShellTime daemon of the machine it runs on: `track` +// hands commands to the daemon on the default socket before reading any config, so a running +// daemon (yours, or a Claude Code cloud session's) would upload the tests' commands to its +// account. It also clears the Claude Code cloud session environment, so the suite gives the same +// results inside a cloud session (where CLAUDE_CODE_REMOTE=true) as on CI. +func TestMain(m *testing.M) { + os.Unsetenv("CLAUDE_CODE_REMOTE") + os.Unsetenv("CLAUDE_CODE_REMOTE_SESSION_ID") + trackSocketPath = filepath.Join(os.TempDir(), fmt.Sprintf("shelltime-commands-test-%d.sock", os.Getpid())) + os.Exit(m.Run()) +} diff --git a/commands/track.go b/commands/track.go index 503dc31..e1bd4df 100644 --- a/commands/track.go +++ b/commands/track.go @@ -57,6 +57,10 @@ var TrackCommand *cli.Command = &cli.Command{ }, } +// trackSocketPath is the daemon socket the fast path tries before reading any config. Tests +// point it elsewhere, so they never hand their commands to a real daemon on this machine. +var trackSocketPath = model.DefaultSocketPath + func commandTrack(c *cli.Context) error { ctx, span := commandTracer.Start(c.Context, "track", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() @@ -101,7 +105,7 @@ func commandTrack(c *cli.Context) error { // storage-engine decision (bolt vs txt), exclude filtering, sync and pruning. // A socket file left behind by a dead daemon still stats as present; if the // send fails, fall through and persist locally instead of dropping the command. - if forwarded, err := forwardTrackToDaemon(ctx, span, model.DefaultSocketPath, cmdPhase, instance, result); forwarded || err != nil { + if forwarded, err := forwardTrackToDaemon(ctx, span, trackSocketPath, cmdPhase, instance, result); forwarded || err != nil { return err } diff --git a/commands/track_cov_test.go b/commands/track_cov_test.go index 736728e..783fd1f 100644 --- a/commands/track_cov_test.go +++ b/commands/track_cov_test.go @@ -22,17 +22,13 @@ import ( "go.opentelemetry.io/otel/trace/noop" ) -// x3SetupTrack isolates HOME, installs a mock ConfigService, and skips the test -// if a real daemon happens to own the hardcoded default socket (which would -// otherwise divert commandTrack down the daemon path). Returns the temp HOME and -// the mock. +// x3SetupTrack isolates HOME and installs a mock ConfigService. TestMain points +// the fast path's socket away from a real daemon, so commandTrack takes the slow +// path. Returns the temp HOME and the mock. func x3SetupTrack(t *testing.T) (string, *model.MockConfigService) { t.Helper() otel.SetTracerProvider(noop.NewTracerProvider()) SKIP_LOGGER_SETTINGS = true - if _, err := os.Stat(model.DefaultSocketPath); err == nil { - t.Skip("default daemon socket present; commandTrack would take the daemon path") - } home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USER", "tester") From dade2748b9d8d2783bfb904092b164d247827f78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:26:49 +0000 Subject: [PATCH 2/2] feat(daemon): collect only AI coding agent telemetry in Claude Code cloud sessions A Claude Code cloud environment's VM isn't one of the user's machines, so ShellTime shouldn't record its shell commands or list it as a host; only the Claude Code session itself belongs in the account. New config key `aiCloudOnly` (model.IsAICloudOnly). Unset, it is on where CLAUDE_CODE_REMOTE=true, which cloud environments set and local machines never do, so environments set up with cloud.bash need no change. An explicit value wins either way. In this mode: - The daemon's socket drops track_pre, track_post and sync messages and answers heartbeats with "disabled". The daemon doesn't open the bolt store or start the sync circuit breaker or heartbeat resync. - `shelltime track` without a daemon stores nothing. `sync`, `alias import`, `dotfiles push` and `hooks install` say why and do nothing. - `shelltime doctor` names the mode and its source under Configuration and skips Shell Hooks. - OTEL events, metrics and spans, `cc pr` and session projects go through as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Um2DevMgViC7Jtb8jaXFF3 --- CLAUDE.md | 1 + cmd/daemon/main.go | 26 +++-- commands/ai_cloud_only.go | 32 ++++++ commands/ai_cloud_only_test.go | 174 +++++++++++++++++++++++++++++ commands/alias.go | 3 + commands/doctor_checks.go | 22 ++++ commands/dotfiles_push.go | 3 + commands/hooks.install.go | 4 + commands/sync.go | 3 + commands/track.go | 6 + daemon/socket.go | 25 ++--- daemon/socket_extra_test.go | 70 ++++++++++++ docs/CONFIG.md | 21 ++++ model/aicode_cloud_session.go | 11 ++ model/aicode_cloud_session_test.go | 45 ++++++++ model/config.go | 3 + model/config_cov_test.go | 3 + model/types.go | 6 + 18 files changed, 436 insertions(+), 22 deletions(-) create mode 100644 commands/ai_cloud_only.go create mode 100644 commands/ai_cloud_only_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 2bd48ec..9adbb5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ Services initialize in `cmd/daemon/main.go`: check enabled flag → create → s - 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 +- AI cloud only (`aiCloudOnly`, `model.IsAICloudOnly`): collect only AI coding agent telemetry and nothing about the machine, so it never becomes a host. Unset, it is on where `CLAUDE_CODE_REMOTE=true` (a Claude Code cloud VM); an explicit value wins. The daemon's socket drops `track_pre`/`track_post`/`sync` and heartbeats, and `cmd/daemon/main.go` skips the bolt store, sync circuit breaker and heartbeat resync; OTEL, `cc pr` and session-project pass through. In the CLI, `track` without a daemon stores nothing, and `sync`, `alias import`, `dotfiles push` and `hooks install` print why and return nil (`skippedInAICloudOnly`). `doctor` reports it under Configuration and skips Shell Hooks ## Commit Rules diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index af72963..b7dabdf 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -87,9 +87,17 @@ func main() { daemon.Init(daemonConfigService, version) model.InjectVar(version) + // In AI cloud only mode (on by default in a Claude Code cloud environment) the socket drops + // commands and heartbeats, so nothing that stores, syncs or resends them is started. + aiCloudOnly := model.IsAICloudOnly(cfg, os.Getenv) + if aiCloudOnly { + slog.Info("AI cloud only mode: only AI coding agent telemetry is collected, no commands, heartbeats or host info", + slog.Bool("fromConfig", cfg.AICloudOnly != nil)) + } + // When the bolt storage engine is enabled, the daemon owns the bolt-backed // command store for its lifetime (bbolt holds an exclusive file lock). - if cfg.Storage != nil && cfg.Storage.Engine == model.StorageEngineBolt { + if !aiCloudOnly && cfg.Storage != nil && cfg.Storage.Engine == model.StorageEngineBolt { store, err := model.NewCommandStore(cfg) if err != nil { slog.Error("Failed to open bolt command store", slog.Any("err", err)) @@ -109,12 +117,14 @@ func main() { } // Start sync circuit breaker service - syncCircuitBreakerService := daemon.NewSyncCircuitBreakerService(pubsub) - if err := syncCircuitBreakerService.Start(ctx); err != nil { - slog.Error("Failed to start sync circuit breaker service", slog.Any("err", err)) - } else { - slog.Info("Sync circuit breaker service started") - defer syncCircuitBreakerService.Stop() + if !aiCloudOnly { + syncCircuitBreakerService := daemon.NewSyncCircuitBreakerService(pubsub) + if err := syncCircuitBreakerService.Start(ctx); err != nil { + slog.Error("Failed to start sync circuit breaker service", slog.Any("err", err)) + } else { + slog.Info("Sync circuit breaker service started") + defer syncCircuitBreakerService.Stop() + } } // Start cleanup timer service if enabled (enabled by default) @@ -145,7 +155,7 @@ func main() { } // Start heartbeat resync service if codeTracking is enabled - if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled { + if !aiCloudOnly && cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled { heartbeatResyncService := daemon.NewHeartbeatResyncService(cfg) if err := heartbeatResyncService.Start(ctx); err != nil { slog.Error("Failed to start heartbeat resync service", slog.Any("err", err)) diff --git a/commands/ai_cloud_only.go b/commands/ai_cloud_only.go new file mode 100644 index 0000000..839616f --- /dev/null +++ b/commands/ai_cloud_only.go @@ -0,0 +1,32 @@ +package commands + +import ( + "context" + "os" + + "github.com/gookit/color" + "github.com/malamtime/cli/model" +) + +const aiCloudOnlyReason = "AI cloud only mode collects only AI coding agent usage on this machine " + + "(aiCloudOnly in the config, on by default in a Claude Code cloud environment)" + +// skippedInAICloudOnly reports whether AI cloud only mode is on for cfg, after saying which +// action it skipped. Commands that would upload commands or anything about the machine stop then. +func skippedInAICloudOnly(cfg model.ShellTimeConfig, action string) bool { + if !model.IsAICloudOnly(cfg, os.Getenv) { + return false + } + color.Yellow.Printf("%s: %s.\n", action, aiCloudOnlyReason) + return true +} + +// configForAICloudOnly reads the config for a command that can run before signing in, so there +// may be none yet; the environment alone decides the mode then. +func configForAICloudOnly(ctx context.Context) model.ShellTimeConfig { + cfg, err := model.NewConfigService(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)).ReadConfigFile(ctx) + if err != nil { + return model.ShellTimeConfig{} + } + return cfg +} diff --git a/commands/ai_cloud_only_test.go b/commands/ai_cloud_only_test.go new file mode 100644 index 0000000..f042b59 --- /dev/null +++ b/commands/ai_cloud_only_test.go @@ -0,0 +1,174 @@ +package commands + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +// aiCloudOnlyCases turns the mode on through the config, then through the Claude Code cloud +// environment with the key unset. +var aiCloudOnlyCases = []struct { + name string + cfg model.ShellTimeConfig + remote string +}{ + {name: "config", cfg: model.ShellTimeConfig{AICloudOnly: new(true)}}, + {name: "cloud environment", remote: "true"}, +} + +// uploadRecorder is an API server that counts the requests it gets. +func uploadRecorder(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + return srv, &requests +} + +// Without a daemon, `track` stores nothing in AI cloud only mode, so a later sync has nothing +// to upload. +func TestCommandTrack_AICloudOnlyStoresNothing(t *testing.T) { + for _, tt := range aiCloudOnlyCases { + t.Run(tt.name, func(t *testing.T) { + _, mc := x3SetupTrack(t) + if tt.remote != "" { + t.Setenv("CLAUDE_CODE_REMOTE", tt.remote) + } + srv, requests := uploadRecorder(t) + cfg := tt.cfg + cfg.Token, cfg.APIEndpoint, cfg.FlushCount = "tok", srv.URL, 3 + cfg.SocketPath = x3UnreadySocket(t) + mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil) + + app := &cli.App{Name: "t", Commands: []*cli.Command{TrackCommand}} + for _, phase := range []string{"pre", "post"} { + require.NoError(t, app.Run([]string{"t", "track", "--phase", phase, "--shell", "bash", "--command", "go test ./...", "--id", "1"})) + } + + _, err := os.Stat(model.GetCommandsStoragePath()) + assert.True(t, os.IsNotExist(err), "no command buffer is written") + assert.Zero(t, requests.Load()) + }) + } +} + +// sync, alias import and dotfiles push send commands or describe this machine, which would make +// it a host: in AI cloud only mode they say so and send nothing. +func TestUploadCommands_AICloudOnlySendNothing(t *testing.T) { + runs := map[string]struct { + cmd *cli.Command + args []string + }{ + "sync": {cmd: SyncCommand, args: []string{"t", "sync"}}, + "alias import": {cmd: AliasCommand, args: []string{"t", "alias", "import"}}, + "dotfiles push": {cmd: DotfilesCommand, args: []string{"t", "dotfiles", "push"}}, + } + for name, run := range runs { + for _, tt := range aiCloudOnlyCases { + t.Run(name+"/"+tt.name, func(t *testing.T) { + mc := setupAliasTest(t) + home := t.TempDir() + t.Setenv("HOME", home) + if tt.remote != "" { + t.Setenv("CLAUDE_CODE_REMOTE", tt.remote) + } + // Something to upload for each command, if it ran + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("alias gs='git status'\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), []byte("export A=1\n"), 0644)) + buffered := model.Command{Shell: "bash", SessionID: 1, Command: "go test ./...", Hostname: "vm", Username: "root", Time: time.Now()} + require.NoError(t, buffered.DoSavePre()) + buffered.Time = buffered.Time.Add(time.Second) + require.NoError(t, buffered.DoUpdate(0)) + + srv, requests := uploadRecorder(t) + cfg := tt.cfg + cfg.Token, cfg.APIEndpoint = "tok", srv.URL + cfg.SocketPath = x3UnreadySocket(t) + mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil) + + app := &cli.App{Name: "t", Commands: []*cli.Command{run.cmd}} + require.NoError(t, app.Run(run.args)) + assert.Zero(t, requests.Load()) + }) + } + } +} + +// hooks install can run before signing in, so it reads the config itself: off in the config wins +// over the cloud environment's default. +func TestCommandHooksInstall_AICloudOnly(t *testing.T) { + tests := []struct { + name string + config string + remote string + installed bool + }{ + {name: "cloud environment without a config", remote: "true", installed: false}, + {name: "on in the config", config: "aiCloudOnly: true\n", installed: false}, + {name: "off in the config in the cloud", config: "aiCloudOnly: false\n", remote: "true", installed: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SKIP_LOGGER_SETTINGS = true + home := t.TempDir() + t.Setenv("HOME", home) + if tt.remote != "" { + t.Setenv("CLAUDE_CODE_REMOTE", tt.remote) + } + base := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER) + require.NoError(t, os.MkdirAll(filepath.Join(base, "bin"), 0755)) + // bash-preexec.sh is pre-seeded so installing the bash hook needs no network + require.NoError(t, os.MkdirAll(filepath.Join(base, "hooks"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "hooks", "bash-preexec.sh"), []byte("# stub\n"), 0644)) + if tt.config != "" { + require.NoError(t, os.WriteFile(filepath.Join(base, "config.yaml"), []byte("token: tok\n"+tt.config), 0600)) + } + + app := &cli.App{Name: "t", Commands: []*cli.Command{HooksInstallCommand}} + require.NoError(t, app.Run([]string{"t", "install"})) + + _, err := os.Stat(filepath.Join(home, ".bashrc")) + assert.Equal(t, tt.installed, err == nil, "bash hook installed") + }) + } +} + +// doctor names the mode and where it comes from, and doesn't ask for shell hooks. +func TestDoctor_AICloudOnly(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + t.Setenv("CLAUDE_CODE_REMOTE", "true") + cfg := doctorTestConfig(t) + mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil) + + env := newDoctorEnv(context.Background(), doctorOptions{offline: true}) + require.True(t, env.aiCloudOnly) + mode := findDoctorResult(t, doctorCheckConfig(env), "config.ai_cloud_only") + assert.Equal(t, doctorInfo, mode.Status) + assert.Contains(t, mode.Message, "Claude Code cloud environment") + hooks := doctorCheckHooks(env) + require.Len(t, hooks, 1) + assert.Equal(t, doctorSkip, hooks[0].Status) + + env.cfg.AICloudOnly = new(true) + assert.Contains(t, findDoctorResult(t, doctorCheckConfig(env), "config.ai_cloud_only").Message, "in your config") + + env.aiCloudOnly = false + assert.False(t, hasDoctorResult(doctorCheckConfig(env), "config.ai_cloud_only")) + assert.NotEqual(t, doctorSkip, findDoctorResult(t, doctorCheckHooks(env), "hooks.shell").Status) +} diff --git a/commands/alias.go b/commands/alias.go index 148785c..e698188 100644 --- a/commands/alias.go +++ b/commands/alias.go @@ -71,6 +71,9 @@ func importAliases(c *cli.Context) error { slog.Error("failed to read config file", slog.Any("err", err)) return err } + if skippedInAICloudOnly(config, "Aliases not imported") { + return nil + } mainEndpoint := model.Endpoint{ APIEndpoint: config.APIEndpoint, diff --git a/commands/doctor_checks.go b/commands/doctor_checks.go index c51d944..9734138 100644 --- a/commands/doctor_checks.go +++ b/commands/doctor_checks.go @@ -69,6 +69,8 @@ type doctorEnv struct { cfgErr error shell string socketPath string + // aiCloudOnly: shell commands and anything else about the machine aren't collected here. + aiCloudOnly bool daemonStatus *daemon.StatusResponse @@ -137,6 +139,11 @@ func newDoctorEnv(ctx context.Context, opts doctorOptions) *doctorEnv { if env.cfgErr == nil && env.cfg.SocketPath != "" { env.socketPath = env.cfg.SocketPath } + if env.cfgErr == nil { + env.aiCloudOnly = model.IsAICloudOnly(env.cfg, os.Getenv) + } else { + env.aiCloudOnly = model.IsAICloudOnly(model.ShellTimeConfig{}, os.Getenv) + } if status, _, err := requestDaemonStatus(env.socketPath, doctorDaemonProbeTimeout); err == nil { env.daemonStatus = status } @@ -354,6 +361,18 @@ func doctorCheckConfig(env *doctorEnv) []doctorResult { } cfg := env.cfg + if env.aiCloudOnly { + source := "on by default in a Claude Code cloud environment" + if cfg.AICloudOnly != nil { + source = "aiCloudOnly is on in your config" + } + results = append(results, doctorResult{ + ID: "config.ai_cloud_only", + Status: doctorInfo, + Message: fmt.Sprintf("AI cloud only mode (%s): only AI coding agent usage is uploaded, never shell commands, editor activity, aliases, dotfiles or this machine.", source), + }) + } + endpointFix := fmt.Sprintf("Set `apiEndpoint: https://api.shelltime.xyz` in %s.", base) if cfg.APIEndpoint == "" { results = append(results, doctorResult{ @@ -647,6 +666,9 @@ var doctorShellFiles = map[string]struct{ rc, hook string }{ } func doctorCheckHooks(env *doctorEnv) []doctorResult { + if env.aiCloudOnly { + return []doctorResult{{ID: "hooks.shell", Status: doctorSkip, Message: "Skipped: AI cloud only mode collects no shell commands, so no hooks are needed (see Configuration)."}} + } if env.shell == "" { return []doctorResult{{ ID: "hooks.shell", diff --git a/commands/dotfiles_push.go b/commands/dotfiles_push.go index ad443e6..bd32c4b 100644 --- a/commands/dotfiles_push.go +++ b/commands/dotfiles_push.go @@ -24,6 +24,9 @@ func pushDotfiles(c *cli.Context) error { slog.Error("failed to read config file", slog.Any("err", err)) return err } + if skippedInAICloudOnly(config, "Dotfiles not pushed") { + return nil + } if config.Token == "" { return fmt.Errorf("no token found, please run 'shelltime auth login' first") diff --git a/commands/hooks.install.go b/commands/hooks.install.go index 25441cb..01032cd 100644 --- a/commands/hooks.install.go +++ b/commands/hooks.install.go @@ -17,6 +17,10 @@ var HooksInstallCommand = &cli.Command{ } func commandHooksInstall(c *cli.Context) error { + if skippedInAICloudOnly(configForAICloudOnly(c.Context), "Shell hooks not installed") { + return nil + } + binFolder := os.ExpandEnv(fmt.Sprintf("$HOME/%s/bin", model.COMMAND_BASE_STORAGE_FOLDER)) _, binFolderErr := os.Stat(binFolder) _, lookPathErr := exec.LookPath("shelltime") diff --git a/commands/sync.go b/commands/sync.go index 56e79fa..cb5d6dc 100644 --- a/commands/sync.go +++ b/commands/sync.go @@ -37,6 +37,9 @@ func commandSync(c *cli.Context) error { slog.Error("failed to read config file", slog.Any("err", err)) return err } + if skippedInAICloudOnly(config, "Nothing synced") { + return nil + } isDryRun := c.Bool("dry-run") return trySyncLocalToServer(ctx, config, syncOptions{ diff --git a/commands/track.go b/commands/track.go index e1bd4df..cfb91bb 100644 --- a/commands/track.go +++ b/commands/track.go @@ -118,6 +118,12 @@ func commandTrack(c *cli.Context) error { return err } + // A daemon in AI cloud only mode drops the event itself; without one, drop it here. + if model.IsAICloudOnly(config, os.Getenv) { + slog.Debug("Command not tracked: AI cloud only mode") + return nil + } + // Check if command should be excluded if model.ShouldExcludeCommand(cmdCommand, config.Exclude) { slog.Debug("Command excluded by pattern", slog.String("command", cmdCommand)) diff --git a/daemon/socket.go b/daemon/socket.go index 38547b9..10b9c96 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -108,6 +108,8 @@ type SocketHandler struct { channel *GoChannel stopChan chan struct{} ccInfoTimer *CCInfoTimerService + // aiCloudOnly drops the commands and heartbeats sent to the socket (model.IsAICloudOnly). + aiCloudOnly bool } func NewSocketHandler(config *model.ShellTimeConfig, ch *GoChannel) *SocketHandler { @@ -116,6 +118,7 @@ func NewSocketHandler(config *model.ShellTimeConfig, ch *GoChannel) *SocketHandl channel: ch, stopChan: make(chan struct{}), ccInfoTimer: NewCCInfoTimerService(config), + aiCloudOnly: model.IsAICloudOnly(*config, os.Getenv), } } @@ -189,31 +192,25 @@ func (p *SocketHandler) handleConnection(conn net.Conn) { switch msg.Type { case SocketMessageTypeStatus: p.handleStatus(conn) - case SocketMessageTypeSync: - buf, err := json.Marshal(msg) - if err != nil { - slog.Error("Error encoding message", slog.Any("err", err)) + case SocketMessageTypeSync, SocketMessageTypeTrackPre, SocketMessageTypeTrackPost: + if p.aiCloudOnly { + slog.Debug("Command message dropped: AI cloud only mode collects no commands", slog.String("messageType", string(msg.Type))) return } - - chMsg := message.NewMessage(watermill.NewUUID(), buf) - if err := p.channel.Publish(PubSubTopic, chMsg); err != nil { - slog.Error("Error to publish topic", slog.Any("err", err)) - } - case SocketMessageTypeTrackPre, SocketMessageTypeTrackPost: buf, err := json.Marshal(msg) if err != nil { - slog.Error("Error encoding track message", slog.Any("err", err)) + slog.Error("Error encoding message", slog.String("messageType", string(msg.Type)), slog.Any("err", err)) return } + chMsg := message.NewMessage(watermill.NewUUID(), buf) if err := p.channel.Publish(PubSubTopic, chMsg); err != nil { - slog.Error("Error publishing track topic", slog.Any("err", err)) + slog.Error("Error to publish topic", slog.String("messageType", string(msg.Type)), slog.Any("err", err)) } case SocketMessageTypeHeartbeat: // Only process heartbeat if codeTracking is enabled - if p.config.CodeTracking == nil || p.config.CodeTracking.Enabled == nil || !*p.config.CodeTracking.Enabled { - slog.Debug("Heartbeat message received but codeTracking is disabled, ignoring") + if p.aiCloudOnly || p.config.CodeTracking == nil || p.config.CodeTracking.Enabled == nil || !*p.config.CodeTracking.Enabled { + slog.Debug("Heartbeat message received but codeTracking is disabled, ignoring", slog.Bool("aiCloudOnly", p.aiCloudOnly)) encoder := json.NewEncoder(conn) encoder.Encode(map[string]string{"status": "disabled"}) return diff --git a/daemon/socket_extra_test.go b/daemon/socket_extra_test.go index de79ca6..0764a8a 100644 --- a/daemon/socket_extra_test.go +++ b/daemon/socket_extra_test.go @@ -141,6 +141,76 @@ func TestSocketHandler_TrackEventPublishes(t *testing.T) { } } +// In AI cloud only mode the socket drops commands and heartbeats, so the VM never becomes a host, +// while the Claude Code session's own data, like the pull requests it opened, still goes through. +func TestSocketHandler_AICloudOnlyDropsCommandsAndHeartbeats(t *testing.T) { + tests := []struct { + name string + config model.ShellTimeConfig + remote string + }{ + {name: "config", config: model.ShellTimeConfig{AICloudOnly: boolPtr(true)}}, + {name: "cloud environment", remote: "true"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.remote != "" { + t.Setenv("CLAUDE_CODE_REMOTE", tt.remote) + } + paths := make(chan string, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths <- r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + config := tt.config + config.Token = "tok" + config.APIEndpoint = server.URL + config.CodeTracking = &model.CodeTracking{Enabled: boolPtr(true)} + handler, socketPath := startHandler(t, &config) + require.True(t, handler.aiCloudOnly) + msgs, err := handler.channel.Subscribe(context.Background(), PubSubTopic) + require.NoError(t, err) + + ctx := context.Background() + cmd := model.Command{Shell: "bash", SessionID: 1, Command: "go test ./...", Username: "root", Hostname: "vm", Time: time.Now()} + require.NoError(t, SendTrackEvent(ctx, socketPath, SocketMessageTypeTrackPre, cmd, time.Now())) + require.NoError(t, SendTrackEvent(ctx, socketPath, SocketMessageTypeTrackPost, cmd, time.Now())) + require.NoError(t, SendLocalDataToSocket(ctx, socketPath, config, time.Now(), + []model.TrackingData{{Command: "ls"}}, model.TrackingMetaData{Hostname: "vm", OS: "linux", Shell: "bash"})) + + conn, err := net.Dial("unix", socketPath) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, json.NewEncoder(conn).Encode(SocketMessage{ + Type: SocketMessageTypeHeartbeat, + Payload: model.HeartbeatPayload{Heartbeats: []model.HeartbeatData{{HeartbeatID: "hb"}}}, + })) + var resp map[string]string + require.NoError(t, json.NewDecoder(conn).Decode(&resp)) + assert.Equal(t, "disabled", resp["status"]) + + require.NoError(t, SendSessionPullRequests(socketPath, "sess-1", []string{"https://github.com/o/r/pull/1"})) + select { + case path := <-paths: + assert.Equal(t, "/api/v1/cc/session-pull-requests", path) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not send the session's pull requests") + } + + select { + case published := <-msgs: + published.Ack() + t.Fatalf("a message was published in AI cloud only mode: %s", published.Payload) + case path := <-paths: + t.Fatalf("a request was sent in AI cloud only mode: %s", path) + case <-time.After(200 * time.Millisecond): + } + }) + } +} + func TestSocketHandler_ListCommands_NilStore(t *testing.T) { // With no commandStore set, list_commands returns an empty (non-nil) slice. prev := commandStore diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 24cf264..95113b6 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -306,6 +306,27 @@ aiCodeOtel: 3. ShellTime auto-detects the source from service.name attribute 4. Data is forwarded to shelltime.xyz for analysis +### AI Cloud Only + +Collect only AI coding agent usage (the AICodeOtel data above) on a machine that isn't yours, such as a Claude Code cloud environment's VM: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `aiCloudOnly` | boolean | on in a Claude Code cloud environment, else off | Collect AI coding agent usage only | + +```yaml +# Collect only AI coding agent usage on this machine +aiCloudOnly: true +``` + +In this mode ShellTime records nothing about the machine, so it never shows up as a host: + +- The daemon drops tracked commands, syncs and editor heartbeats, and doesn't start the services that store or resend them. +- `shelltime track` without a daemon stores nothing, and `shelltime sync`, `alias import`, `dotfiles push` and `hooks install` say so and do nothing. +- Claude Code and Codex sessions, prompts, tokens, cost and the pull requests they open are collected as usual. + +You don't need to set it in a Claude Code cloud environment ([cloud.bash](https://github.com/shelltime/installation/blob/main/cloud.bash)): when `aiCloudOnly` is unset, the mode is on wherever `CLAUDE_CODE_REMOTE=true`, which cloud environments set and your own machines never do. To track a cloud VM's commands anyway, set `aiCloudOnly: false`. `shelltime doctor` shows whether the mode is on and why. + ## Codex Usage Tracking Codex usage has two separate data paths: diff --git a/model/aicode_cloud_session.go b/model/aicode_cloud_session.go index 718a76c..940e5e3 100644 --- a/model/aicode_cloud_session.go +++ b/model/aicode_cloud_session.go @@ -21,6 +21,17 @@ func CloudSessionIDFromEnv(getenv func(string) string) string { return strings.TrimSpace(getenv("CLAUDE_CODE_REMOTE_SESSION_ID")) } +// IsAICloudOnly reports whether this machine collects only AI coding agent telemetry, and no +// shell commands, editor heartbeats, aliases, dotfiles or anything else that would make it a +// host. cfg.AICloudOnly decides when it is set; unset, the mode is on in a Claude Code cloud +// environment, a throwaway VM that isn't one of the user's machines. +func IsAICloudOnly(cfg ShellTimeConfig, getenv func(string) string) bool { + if cfg.AICloudOnly != nil { + return *cfg.AICloudOnly + } + return getenv("CLAUDE_CODE_REMOTE") == "true" +} + // 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 diff --git a/model/aicode_cloud_session_test.go b/model/aicode_cloud_session_test.go index c76adcc..de98640 100644 --- a/model/aicode_cloud_session_test.go +++ b/model/aicode_cloud_session_test.go @@ -4,9 +4,12 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestCloudSessionIDFromEnv(t *testing.T) { @@ -29,6 +32,48 @@ func TestCloudSessionIDFromEnv(t *testing.T) { } } +func TestIsAICloudOnly(t *testing.T) { + on, off := true, false + cloud := map[string]string{"CLAUDE_CODE_REMOTE": "true", "CLAUDE_CODE_REMOTE_SESSION_ID": "cse_01AbC"} + tests := []struct { + name string + cfg ShellTimeConfig + env map[string]string + want bool + }{ + {name: "local machine", env: map[string]string{"TERM_PROGRAM": "ghostty"}, want: false}, + {name: "cloud environment", env: cloud, want: true}, + {name: "remote flag off", env: map[string]string{"CLAUDE_CODE_REMOTE": "false"}, want: false}, + {name: "on in the config", cfg: ShellTimeConfig{AICloudOnly: &on}, want: true}, + {name: "off in the config wins in the cloud", cfg: ShellTimeConfig{AICloudOnly: &off}, env: cloud, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsAICloudOnly(tt.cfg, func(k string) string { return tt.env[k] })) + }) + } +} + +// aiCloudOnly is read from either config file, and `shelltime auth` never writes it, so an +// unset value keeps following the environment. +func TestAICloudOnlyConfigFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("token: tok\n"), 0600)) + cfg, err := NewConfigService(dir).ReadConfigFile(context.Background()) + require.NoError(t, err) + require.Nil(t, cfg.AICloudOnly) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.local.yaml"), []byte("aiCloudOnly: true\n"), 0600)) + cfg, err = NewConfigService(dir).ReadConfigFile(context.Background()) + require.NoError(t, err) + require.NotNil(t, cfg.AICloudOnly) + require.True(t, *cfg.AICloudOnly) + + written, err := yaml.Marshal(DefaultConfig) + require.NoError(t, err) + require.NotContains(t, string(written), "aiCloudOnly") +} + // 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) { diff --git a/model/config.go b/model/config.go index 2ec18ea..161dd29 100644 --- a/model/config.go +++ b/model/config.go @@ -182,6 +182,9 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.CodeTracking != nil { base.CodeTracking = local.CodeTracking } + if local.AICloudOnly != nil { + base.AICloudOnly = local.AICloudOnly + } if local.Proxy != nil { base.Proxy = local.Proxy } diff --git a/model/config_cov_test.go b/model/config_cov_test.go index 6a4a910..3e10ba4 100644 --- a/model/config_cov_test.go +++ b/model/config_cov_test.go @@ -39,6 +39,7 @@ func TestMergeConfig_AllOverrides(t *testing.T) { LogCleanup: &LogCleanup{Enabled: &truthy, ThresholdMB: 42}, SocketPath: "/tmp/local.sock", CodeTracking: &CodeTracking{Token: "ct"}, + AICloudOnly: &truthy, Proxy: &ProxyConfig{URL: "socks5://127.0.0.1:1080"}, Storage: &StorageConfig{Engine: StorageEngineBolt}, } @@ -61,6 +62,8 @@ func TestMergeConfig_AllOverrides(t *testing.T) { assert.EqualValues(t, 42, base.LogCleanup.ThresholdMB) assert.Equal(t, "/tmp/local.sock", base.SocketPath) require.NotNil(t, base.CodeTracking) + require.NotNil(t, base.AICloudOnly) + assert.True(t, *base.AICloudOnly) require.NotNil(t, base.Proxy) assert.Equal(t, "socks5://127.0.0.1:1080", base.Proxy.URL) require.NotNil(t, base.Storage) diff --git a/model/types.go b/model/types.go index 39dae87..1c3e0ab 100644 --- a/model/types.go +++ b/model/types.go @@ -85,6 +85,12 @@ type ShellTimeConfig struct { // CodeTracking configuration for coding activity heartbeat tracking CodeTracking *CodeTracking `toml:"codeTracking" yaml:"codeTracking" json:"codeTracking"` + // AICloudOnly collects only AI coding agent telemetry (aiCodeOtel) and nothing about the + // machine: no shell commands, editor heartbeats, aliases or dotfiles, so it never becomes a + // host. Unset, it is on in a Claude Code cloud environment (CLAUDE_CODE_REMOTE=true), whose + // VMs aren't the user's machines. See IsAICloudOnly. + AICloudOnly *bool `toml:"aiCloudOnly,omitempty" yaml:"aiCloudOnly,omitempty" json:"aiCloudOnly,omitempty"` + // LogCleanup configuration for automatic log file cleanup in daemon LogCleanup *LogCleanup `toml:"logCleanup" yaml:"logCleanup" json:"logCleanup"`