diff --git a/timeout/.noidle.example b/timeout/.noidle.example index 86442a1d..631adda2 100644 --- a/timeout/.noidle.example +++ b/timeout/.noidle.example @@ -1,15 +1,15 @@ # Example .noidle configuration for CLI Watcher # # This file shows all available configuration options. -# For minimal config, see .noidle.minimal (just 'enabled: true' is enough!) +# Enablement is admin-controlled via CLI_ACTIVITY_TRACKER_ENABLED env var (CheCluster CR or ConfigMap). # # Place this file in your project directory, or in $HOME/.noidle -# You can also set CLI_WATCHER_CONFIG environment variable to specify a custom path. +# You can also set CLI_ACTIVITY_TRACKER_CONFIG environment variable to specify a custom path. # # NOTE: Comments (lines starting with #) are part of YAML syntax and work fine. # Copy any section below directly into your .noidle file. -enabled: true +# enabled: true # DEPRECATED: Use CLI_ACTIVITY_TRACKER_ENABLED env var instead (admin-controlled) checkPeriod: 30 # How often to check for active processes (accepts: 30, 30s, etc.) activityWindow: 25m # How long to wait for activity from interactive processes gracePeriod: 5m # All processes prevent idling when this young diff --git a/timeout/.noidle.minimal b/timeout/.noidle.minimal index 7fe60145..07be096c 100644 --- a/timeout/.noidle.minimal +++ b/timeout/.noidle.minimal @@ -1,14 +1,18 @@ # Minimal .noidle configuration # -# This is the absolute minimum needed to enable CLI watcher. -# All user processes will be watched with smart defaults. +# CLI Watcher enablement is controlled by the CLI_ACTIVITY_TRACKER_ENABLED environment +# variable (set by the administrator via CheCluster CR or ConfigMap). +# The 'enabled' field in .noidle is deprecated and ignored. +# +# This file is optional — use it only to tune timing or command behavior +# within admin-defined bounds. # # NOTE: Comments (lines starting with #) are supported in YAML. # You can copy these examples directly into your .noidle file. -enabled: true +# enabled: true # DEPRECATED: Use CLI_ACTIVITY_TRACKER_ENABLED env var instead -# That's it! With just this, the CLI watcher will: +# With the CLI Watcher enabled by the administrator, it will: # # ✅ Watch ALL user-initiated processes (processes with TTY from user terminals) # ✅ Auto-detect interactive processes (vim, python REPL, etc.) vs work processes (builds, deploys) diff --git a/timeout/CLI-WATCHER.md b/timeout/CLI-WATCHER.md index 7f4fd5c1..4b35091c 100644 --- a/timeout/CLI-WATCHER.md +++ b/timeout/CLI-WATCHER.md @@ -11,180 +11,282 @@ The watcher periodically scans `/proc` to detect **all user-initiated CLI proces - **Configured commands** (`watchedCommands`) allow you to override auto-detection behavior - **Unconfigured commands** are intelligently classified as interactive or work processes after grace period -## Upgrading from Previous Versions +## Configuration -**⚠️ BREAKING BEHAVIORAL CHANGE:** The CLI Watcher now watches **ALL user-initiated terminal processes** by default, not just those explicitly listed in `watchedCommands`. +CLI Watcher configuration has three layers: -### What Changed +1. **Administrator configuration** (CheCluster CR) - cluster-wide policy via `spec.devEnvironments.cliActivityTracker` fields, propagated by the Che operator as environment variables to all workspace containers +2. **Administrator configuration** (environment variables / ConfigMap) - namespace-level or per-workspace overrides via `CLI_ACTIVITY_TRACKER_*` env vars +3. **User configuration** (`.noidle` file) - per-project or workspace-wide tuning within admin-defined bounds -**Before (old behavior):** -- Only commands listed in `watchedCommands` were monitored -- Other processes were completely ignored -- Only `tail` was globally excluded +### Configuration Precedence -**After (new behavior):** -- **ALL user processes with TTY are monitored automatically** -- `watchedCommands` now **overrides auto-detection** for specific commands (not required to enable watching) -- `tail`, `watch`, `top`, `htop` are now **always ignored** (expanded exclusion list) +``` +Environment variables (admin) > .noidle file (user, stricter only) > Adaptive defaults +``` -### Impact on Your Workspace +- **`enabled`**: Always controlled by the `CLI_ACTIVITY_TRACKER_ENABLED` env var or its default. The `.noidle` `enabled` field is **deprecated and ignored**. +- **Timing params** (`checkPeriod`, `activityWindow`, `gracePeriod`, `maxProcessAge`): Admin env vars set ceilings. Users can make values **stricter** (shorter) via `.noidle`, but **cannot loosen** (lengthen) beyond the admin ceiling. +- **`watchedCommands` / `ignoredCommands`**: User-only (`.noidle` file). Not configurable via env vars. +- **`verbose`**: Admin/env-only (`CLI_ACTIVITY_TRACKER_VERBOSE`). Not configurable via `.noidle` — it's an operational logging toggle, not a per-project idling policy. -1. **Workspaces may stay active longer** - processes that were previously ignored (shells, scripts, REPLs) now prevent idling -2. **Commands in `watchedCommands` may behave differently**: - - If you configured `watch`, `top`, or `htop` → now ignored with a warning - - If you only listed specific commands → other user processes are now also monitored -3. **Auto-detection may differ from your expectations** - interactive processes (vim, python REPL) only prevent idling when actively used +### Administrator Configuration (Environment Variables) -### Migration Steps +Cluster and DevWorkspace administrators control CLI Watcher behavior through environment variables injected into the che-machine-exec container. These are set at pod creation time and are immutable for the container lifetime. -**If you have an existing `.noidle` configuration:** +#### Environment Variables -1. **Review your current `watchedCommands` list** - ```yaml - # Old config - only these were watched - watchedCommands: - - helm - - kubectl - - watch # ⚠️ Now globally ignored! - ``` +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `CLI_ACTIVITY_TRACKER_ENABLED` | boolean | `false` | Master switch. Set to `true` to enable CLI Watcher cluster-wide. | +| `CLI_ACTIVITY_TRACKER_CHECK_PERIOD` | duration | `60s` | How often to scan `/proc` for active processes. | +| `CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW` | duration | adaptive (see [Adaptive Defaults](#adaptive-defaults-calculated-from-workspace-idle-timeout)) | How long to wait for input from interactive processes before considering them idle. | +| `CLI_ACTIVITY_TRACKER_GRACE_PERIOD` | duration | adaptive (see [Adaptive Defaults](#adaptive-defaults-calculated-from-workspace-idle-timeout)) | All processes unconditionally prevent idling when younger than this. | +| `CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE` | duration | `6h` | Safety limit. Processes older than this stop preventing idling. | +| `CLI_ACTIVITY_TRACKER_VERBOSE` | boolean | `false` | Promotes activity-detection details (which process was detected, why it does or doesn't prevent idling) from Debug to Info level, without needing `LOG_LEVEL=debug` for the whole application. | -2. **Understand the new behavior**: - - All user terminal processes are now watched (helm, kubectl, vim, bash scripts, etc.) - - `watchedCommands` is now for **overriding** auto-detection, not enabling watching - - Remove `watch`, `top`, `htop` from your config (they're always ignored) +**Duration format**: Accepts Go duration strings (`30s`, `5m`, `1h`, `1h30m`) or plain integers (treated as seconds). -3. **Option A: Embrace auto-detection** (recommended for most users) - ```yaml - # Minimal config - let auto-detection handle everything - enabled: true - ``` - The watcher will automatically distinguish interactive (vim, REPLs) from work processes (builds, deploys). - -4. **Option B: Restrict to specific commands only** - ```yaml - enabled: true - - # Override auto-detection for specific commands - watchedCommands: - - helm - - kubectl - - # Add all other commands you DON'T want watched - ignoredCommands: - - bash - - sh - - python3 - - node - # ... any other commands you want to ignore - ``` +**Boolean format**: Accepts `true`, `false`, `1`, `0`, `t`, `f`, `TRUE`, `FALSE`, `True`, `False`, `T`, `F`. + +#### Additional Environment Variables + +| Variable | Description | +|----------|-------------| +| `CLI_ACTIVITY_TRACKER_CONFIG` | Override `.noidle` config file path (for user-level config) | +| `PROJECT_SOURCE` | Starting point for upward `.noidle` search | +| `PROJECTS_ROOT` | Stop point for upward `.noidle` search (defaults to `/`) | + +#### Configuring via CheCluster Custom Resource (Recommended) -5. **Test in a non-production workspace first** - verify idle timeout behavior matches your expectations +The recommended way to configure CLI Watcher cluster-wide is through the CheCluster custom resource. The Che operator reads these fields and propagates them as `CLI_ACTIVITY_TRACKER_*` environment variables to all workspace containers via the `che-user-settings` ConfigMap. -### Examples +##### CheCluster CR Fields + +| Field (under `spec.devEnvironments.cliActivityTracker`) | Type | Default | Maps to env var | +|---|---|---|---| +| `enabled` | bool | `false` | `CLI_ACTIVITY_TRACKER_ENABLED` | +| `secondsOfCheckPeriod` | int32 | not set (adaptive) | `CLI_ACTIVITY_TRACKER_CHECK_PERIOD` | +| `secondsOfActivityWindow` | int32 | not set (adaptive) | `CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW` | +| `secondsOfGracePeriod` | int32 | not set (adaptive) | `CLI_ACTIVITY_TRACKER_GRACE_PERIOD` | +| `secondsOfMaxProcessAge` | int32 | not set (`6h`) | `CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE` | + +Timing fields are in **seconds**. Set to `-1` to use the default value calculated by che-machine-exec (see [Adaptive Defaults](#adaptive-defaults-calculated-from-workspace-idle-timeout)). When a timing field is not set (or set to `-1`), the corresponding env var is not written to the ConfigMap, and che-machine-exec uses its adaptive defaults. + +##### Full Example + +Save as `cli-watcher-config.yaml`: -**Example 1: Old config watching only deployments** ```yaml -# Before -watchedCommands: - - helm - - kubectl - - odo +apiVersion: org.eclipse.che/v2 +kind: CheCluster +metadata: + name: eclipse-che + namespace: eclipse-che +spec: + devEnvironments: + cliActivityTracker: + enabled: true + secondsOfCheckPeriod: 60 + secondsOfActivityWindow: 900 # 15 minutes + secondsOfGracePeriod: 180 # 3 minutes + secondsOfMaxProcessAge: 14400 # 4 hours ``` -**After migration (Option A - auto-detect):** -```yaml -# After - auto-detection handles everything -enabled: true +Apply with: -# Optional: Force these to always prevent idling (skip auto-detection) -watchedCommands: - - helm - - kubectl - - odo +```bash +kubectl apply -f cli-watcher-config.yaml ``` -**Example 2: Old config with globally-excluded commands** +##### Minimal Example (enable with adaptive defaults) + +Save as `cli-watcher-enable.yaml`: + ```yaml -# Before -watchedCommands: - - watch # Monitoring logs - - kubectl +apiVersion: org.eclipse.che/v2 +kind: CheCluster +metadata: + name: eclipse-che + namespace: eclipse-che +spec: + devEnvironments: + cliActivityTracker: + enabled: true ``` -**After migration:** -```yaml -# After - remove 'watch' (now globally ignored) -enabled: true +Apply with: -watchedCommands: - - kubectl # Keep kubectl if you want to override auto-detection - -# WARNING will be logged: -# "You configured [watch] in watchedCommands, but these are globally excluded" +```bash +kubectl apply -f cli-watcher-enable.yaml ``` -### Verification +##### Quick Changes with `kubectl patch` -After updating your configuration: +For one-off changes without a file: -1. Check logs for warnings about globally-excluded commands -2. Monitor workspace idle timeout behavior -3. Use `LOG_LEVEL=debug` to see which processes are detected and classified -4. Refer to the [Testing](#testing) section for validation scenarios +```bash +# Enable CLI Watcher with adaptive defaults +kubectl patch checluster/eclipse-che -n eclipse-che --type=merge \ + -p '{"spec":{"devEnvironments":{"cliActivityTracker":{"enabled":true}}}}' -### Rollback +# Enable with custom timing +kubectl patch checluster/eclipse-che -n eclipse-che --type=merge \ + -p '{"spec":{"devEnvironments":{"cliActivityTracker":{"enabled":true,"secondsOfActivityWindow":900,"secondsOfGracePeriod":180}}}}' -If the new behavior doesn't suit your workflow: +# Disable CLI Watcher +kubectl patch checluster/eclipse-che -n eclipse-che --type=merge \ + -p '{"spec":{"devEnvironments":{"cliActivityTracker":{"enabled":false}}}}' +``` -1. Use `ignoredCommands` to exclude unwanted processes -2. Set explicit `interactive` modes in `watchedCommands` to override auto-detection -3. Contact your platform administrator if workspace idle policies need adjustment +##### Important Notes -## Configuration +- **Scope**: Cluster-wide — the operator propagates these values to all user namespaces automatically. +- **Restart required**: Changes to the CheCluster CR require a workspace restart (stop and start) to take effect, since environment variables are set at pod creation time. +- **Do not mix with custom ConfigMaps**: If you configure CLI Watcher via the CheCluster CR, do not also set the same `CLI_ACTIVITY_TRACKER_*` keys in a custom ConfigMap with `controller.devfile.io/mount-to-devworkspace` label. Both ConfigMaps will be mounted, and the effective value depends on unpredictable mount order. -### Configuration File Requirement +#### Configuring via Kubernetes ConfigMap -**IMPORTANT**: The CLI Watcher requires a configuration file to enable watching. Without a config file, NO processes will be watched and workspaces will idle normally. +**Note**: If the Che operator is deployed and the CheCluster CR includes `cliActivityTracker` fields, the [CheCluster CR approach](#configuring-via-checluster-custom-resource-recommended) is preferred. Use the ConfigMap approach below for environments without the Che operator, or for per-namespace overrides that differ from the cluster-wide CheCluster CR settings (using different, non-overlapping env var keys only). + +To inject CLI Watcher env vars into workspace containers, create a labeled ConfigMap in the **user's namespace**. The env vars are mounted into **all DevWorkspace containers** (including the che-machine-exec sidecar). -**Minimum Required Configuration**: ```yaml -enabled: true +apiVersion: v1 +kind: ConfigMap +metadata: + name: cli-watcher-config + namespace: + labels: + controller.devfile.io/mount-to-devworkspace: "true" + controller.devfile.io/watch-configmap: "true" + annotations: + controller.devfile.io/mount-as: env + controller.devfile.io/mount-on-start: "true" +data: + CLI_ACTIVITY_TRACKER_ENABLED: "true" + CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW: "15m" + CLI_ACTIVITY_TRACKER_GRACE_PERIOD: "3m" + CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE: "4h" +``` + +**Minimal admin configuration** (enable with all defaults): + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cli-watcher-config + namespace: + labels: + controller.devfile.io/mount-to-devworkspace: "true" + controller.devfile.io/watch-configmap: "true" + annotations: + controller.devfile.io/mount-as: env + controller.devfile.io/mount-on-start: "true" +data: + CLI_ACTIVITY_TRACKER_ENABLED: "true" +``` + +**Important notes**: + +- **Scope**: The ConfigMap applies to all workspaces in the namespace where it is created. In Eclipse Che, each user has their own namespace — a ConfigMap created in one user's namespace only affects that user's workspaces. To enforce a cluster-wide policy, an administrator must create the ConfigMap in every user's namespace. +- **Restart behavior**: The `controller.devfile.io/mount-on-start` annotation (included above) ensures the ConfigMap is only mounted when a workspace starts. Without it, creating or updating a ConfigMap with the `controller.devfile.io/mount-to-devworkspace` label **restarts all running workspaces** in that namespace. +- **Selective targeting**: Use `controller.devfile.io/mount-to-devworkspace-include` or `controller.devfile.io/mount-to-devworkspace-exclude` annotations with comma-separated workspace name patterns to target specific workspaces. + +See the Eclipse Che documentation for details: +- [Mounting ConfigMaps](https://eclipse.dev/che/docs/stable/end-user-guide/mounting-configmaps/) +- [Customizing Cloud Development Environments](https://che.eclipseprojects.io/2024/02/05/@mario.loriedo-cde-customization.html) + +#### Configuring via DevWorkspace Attribute + +To configure CLI Watcher for a **single workspace**, use the `workspaceEnv` attribute in the DevWorkspace spec. This injects env vars into all containers in that workspace: + +```yaml +apiVersion: workspace.devfile.io/v1alpha2 +kind: DevWorkspace +metadata: + name: my-workspace +spec: + template: + attributes: + workspaceEnv: + - name: CLI_ACTIVITY_TRACKER_ENABLED + value: "true" + - name: CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW + value: "15m" ``` -That's it! With just this one line: -- ✅ ALL user processes are automatically watched -- ✅ Interactive vs. work processes are auto-detected -- ✅ All settings use smart defaults (grace period: 5min, activity window: 25min, max age: 6h) -- ✅ Passive monitoring tools (tail, watch, top, htop) are automatically ignored +**Scope**: Per-workspace only. For namespace-wide or cluster-wide configuration, use the [ConfigMap approach](#configuring-via-kubernetes-configmap) instead. + +#### Ceiling Enforcement + +When an admin sets a timing env var, it becomes a **ceiling** that users cannot exceed via `.noidle`: -### Configuration File Locations +- If a user sets `activityWindow: 30m` in `.noidle` but the admin set `CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW=15m`, the value is **clamped to 15m** and a log message explains why. +- If a user sets `activityWindow: 10m` (stricter than the 15m ceiling), it is **accepted**. +- If the admin does not set a timing env var, the user's `.noidle` value is used without restriction. -The CLI Watcher looks for a `.noidle` configuration file in the following order: +Every resolved parameter is logged with its source (see [Logging](#logging)). -1. **Explicit override**: Set via `CLI_WATCHER_CONFIG` environment variable +#### Important: Environment Variables Are Immutable + +Environment variables are set at **pod creation time** and cannot be changed for a running workspace. Changing env var values in the DevWorkspace spec requires a workspace restart (stop and start). For runtime tuning without restart, users can modify the `.noidle` file (which is hot-reloaded) within admin-defined bounds. + +### User Configuration (`.noidle` File) + +Users can tune CLI Watcher behavior per-project using a `.noidle` YAML file. This is optional when the administrator has enabled the watcher via `CLI_ACTIVITY_TRACKER_ENABLED`. + +#### Configuration File Locations + +The CLI Watcher looks for a `.noidle` file in this order: + +1. **Explicit override**: Path from `CLI_ACTIVITY_TRACKER_CONFIG` environment variable 2. **Project directory**: Search upward from `$PROJECT_SOURCE` to `$PROJECTS_ROOT` for `.noidle` 3. **Home directory**: Fallback to `$HOME/.noidle` -If no config file is found, the watcher runs but does NOT prevent idling (waits for config to appear). +If no `.noidle` file is found, the watcher uses env var values and adaptive defaults. -### Basic Configuration (Backward Compatible) +#### Deprecated: `enabled` Field +**The `enabled` field in `.noidle` is deprecated and ignored.** Enablement is controlled exclusively by the `CLI_ACTIVITY_TRACKER_ENABLED` environment variable (or its default value). If `.noidle` contains `enabled: true` or `enabled: false`, a deprecation warning is logged and the value is disregarded. + +**Before** (old behavior): ```yaml +# .noidle - this no longer controls enablement enabled: true +``` + +**After** (new behavior): +```bash +# Enablement is admin-controlled via environment variable +CLI_ACTIVITY_TRACKER_ENABLED=true +``` + +#### What `.noidle` Still Controls + +- **Timing overrides** (within admin ceilings): `checkPeriod`, `activityWindow`, `gracePeriod`, `maxProcessAge` +- **Command-specific behavior**: `watchedCommands`, `ignoredCommands` +- **Hot-reload**: The `.noidle` file is re-read on every check cycle. Changes take effect without workspace restart. + +#### Basic Configuration + +```yaml +# .noidle - timing and command overrides only checkPeriod: 30 +activityWindow: 20m +gracePeriod: 3m + watchedCommands: - helm - - odo - kubectl ``` -**Note**: The `watchedCommands` list is **optional** - it's used to **override** auto-detection, not to enable watching. Without this list, ALL user processes are still watched with smart defaults. +**Note**: The `watchedCommands` list is **optional**. It overrides auto-detection for specific commands, not enables watching. Without this list, ALL user processes are still watched with smart defaults. -This simple string format forces listed commands to be **non-interactive** - they always prevent idling when running, regardless of whether they're actively doing work. +#### Advanced Configuration - Override Auto-Detection (Optional) -### Advanced Configuration - Override Auto-Detection (Optional) - -**⚠️ You probably don't need this section!** The CLI Watcher auto-detects process types correctly in most cases. +**You probably don't need this section.** The CLI Watcher auto-detects process types correctly in most cases. **Only override auto-detection when:** - Auto-detection misclassifies a specific command @@ -193,17 +295,17 @@ This simple string format forces listed commands to be **non-interactive** - the **Two escape hatches available:** -#### 1. **`watchedCommands`** - Fix misclassification (process still watched, mode corrected) +##### 1. `watchedCommands` - Fix misclassification (process still watched, mode corrected) ```yaml watchedCommands: - name: myBuildTool - interactive: false # Auto-detected as interactive, but it's actually a build → force non-interactive - + interactive: false # Auto-detected as interactive, but it's actually a build + - name: myREPL - interactive: true # Auto-detected as work process, but it's interactive → force interactive + interactive: true # Auto-detected as work process, but it's interactive ``` -#### 2. **`ignoredCommands`** - Stop watching entirely (process never prevents idling) +##### 2. `ignoredCommands` - Stop watching entirely (process never prevents idling) ```yaml ignoredCommands: - weirdSystemDaemon # Has TTY but shouldn't be watched at all @@ -211,35 +313,34 @@ ignoredCommands: ``` **Warning:** Misconfiguring can break workspace idling: -- Setting `sleep` as `interactive: true` → Long-running tasks interrupted ❌ -- Setting `vim` as `interactive: false` → Idle editor prevents idling forever ❌ -- Over-using `ignoredCommands` → Important work not tracked ❌ +- Setting `sleep` as `interactive: true` - Long-running tasks interrupted +- Setting `vim` as `interactive: false` - Idle editor prevents idling forever +- Over-using `ignoredCommands` - Important work not tracked -#### Full example with time settings: +#### Full Example with Time Settings ```yaml -enabled: true checkPeriod: 30 # How often to check for active processes (default: 60 seconds) -activityWindow: 25m # How long to wait for activity from interactive processes (default: 25m) -gracePeriod: 5m # All processes prevent idling when this young (default: 5m) +activityWindow: 25m # How long to wait for activity from interactive processes +gracePeriod: 5m # All processes prevent idling when this young # Optional: Override auto-detection for specific commands watchedCommands: # Force long-running commands to always prevent idling (skip auto-detection) - helm - kubectl - + # Force interactive CLIs to always check for user input activity - name: claude - interactive: true # Force interactive (always check for user input) - - # Let auto-detection decide (foreground + TTY read → interactive) + interactive: true + + # Let auto-detection decide (foreground + TTY read -> interactive) - name: vim - interactive: auto # Auto-detect (same as unconfigured, but explicit) - + interactive: auto + # Force non-interactive mode (always prevent idling) - name: npm - interactive: false # Force non-interactive (always prevent idling) + interactive: false # Optional: Completely ignore certain commands ignoredCommands: @@ -247,7 +348,7 @@ ignoredCommands: - debugHelper ``` -**Remember**: +**Remember**: - **Unconfigured commands**: Auto-detected with `interactive: auto` behavior after grace period - **`watchedCommands` entries**: Use your explicit `interactive` setting instead of auto-detection - **`ignoredCommands` entries**: Never watched, never prevent idling (like `tail`, `watch`, `top`, `htop`) @@ -260,11 +361,11 @@ The `interactive` field controls how the watcher determines if a process should |------|--------|----------| | **Non-interactive** (default) | `false`, `no`, or omit field | Always prevent idling when the process is running. Best for build tools, deployment commands, etc. | | **Interactive** | `true`, `yes` | Force activity checking. Only prevent idling if process has recent user input (TTY access time). Best for interactive CLIs like editors, REPLs, or AI assistants. | -| **Auto-detect** | `auto` | Detect interactivity by checking if process is foreground AND has read from TTY. If yes → check activity; if no → always prevent idling. | +| **Auto-detect** | `auto` | Detect interactivity by checking if process is foreground AND has read from TTY. If yes - check activity; if no - always prevent idling. | ## ForceWatch Override Option -**⚠️ USE WITH EXTREME CAUTION ⚠️** +**USE WITH EXTREME CAUTION** The `forceWatch` field allows you to override the always-ignored commands list for specific commands. This should **rarely be needed** as always-ignored commands (`tail`, `watch`, `top`, `htop`) are passive monitoring tools that don't indicate active work. @@ -281,89 +382,109 @@ The `forceWatch` field allows you to override the always-ignored commands list f - Specialized monitoring tools that indicate active development **Invalid use cases** (common mistakes): -- Making `tail -f logfile` prevent idling → Logs aren't active work -- Making `top` prevent idling → Process monitoring isn't active work -- Making `watch kubectl get pods` prevent idling → Passive monitoring isn't active work +- Making `tail -f logfile` prevent idling - Logs aren't active work +- Making `top` prevent idling - Process monitoring isn't active work +- Making `watch kubectl get pods` prevent idling - Passive monitoring isn't active work -### Configuration Example +### Accepted Values -```yaml -watchedCommands: - # WRONG: Don't do this for actual monitoring tools - - name: watch - forceWatch: true # ❌ Bad - passive monitoring shouldn't prevent idling - - # VALID: Custom work script that happens to be named 'watch' - - name: watch - interactive: false - forceWatch: true # ✅ OK - custom script that actually does work -``` +- `true`, `yes` - Override always-ignored list (monitor this command) +- `false`, `no` - Respect always-ignored list (default behavior) +- Omit field - Same as `false` (respect always-ignored list) -### Accepted Values +## Default Values and Adaptive Calculation + +The CLI Watcher uses **smart defaults** that adapt to the workspace idle timeout (`SECONDS_OF_DW_INACTIVITY_BEFORE_IDLING`) when available. + +### Fixed Defaults (always the same) + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `enabled` | `false` | CLI Watcher is disabled by default | +| `interactive` | `no` | Backward compatible - always prevent idling | +| `maxProcessAge` | `6h` | Safety limit to prevent indefinite idling prevention | +| `checkPeriod` | `60s` | Process scan interval | -- `true`, `yes` → Override always-ignored list (monitor this command) -- `false`, `no` → Respect always-ignored list (default behavior) -- Omit field → Same as `false` (respect always-ignored list) +### Adaptive Defaults (calculated from workspace idle timeout) -### Warning Messages +When **workspace idle timeout is available** (e.g., 30 minutes), the timing defaults are calculated to fit within the idle window: -When you configure always-ignored commands without `forceWatch: true`, you'll see: +#### Grace Period Calculation ``` -WARNING: You configured [watch, top] in watchedCommands, but these are globally excluded (always ignored) +gracePeriod = min(5m, 15% of idleTimeout) ``` -This usually means you should **remove those commands from your config**, not add `forceWatch: true`. +- Clamped to minimum `1m` +- Examples: + - 30m idle timeout: `min(5m, 4m30s)` = **4m30s** + - 15m idle timeout: `min(5m, 2m15s)` = **2m15s** + - 60m idle timeout: `min(5m, 9m)` = **5m** -### Default Values +#### Activity Window Calculation -The CLI Watcher uses **smart defaults** that adapt to your workspace idle timeout when possible: +``` +activityWindow = idleTimeout - gracePeriod - safetyBuffer +safetyBuffer = min(5m, 20% of idleTimeout) +``` -#### Fixed Defaults (always the same): -- `interactive`: `no` (backward compatible - always prevent idling) -- `maxProcessAge`: `6h` (safety limit to prevent indefinite idling prevention) -- `checkPeriod`: `60` seconds +- Clamped to minimum `2m` +- Examples: + - 30m idle timeout: `30m - 4m30s - min(5m, 6m)` = `30m - 4m30s - 5m` = **20m30s** + - 15m idle timeout: `15m - 2m15s - min(5m, 3m)` = `15m - 2m15s - 3m` = **9m45s** + - 60m idle timeout: `60m - 5m - min(5m, 12m)` = `60m - 5m - 5m` = **50m** -#### Adaptive Defaults (calculated from workspace idle timeout): +#### Why Adaptive? -When **workspace idle timeout is available** (e.g., 30 minutes): -- `gracePeriod`: Smaller of `5m` or `15%` of idle timeout -- `activityWindow`: `idle timeout - gracePeriod - buffer` - - Buffer is smaller of `5m` or `20%` of idle timeout - - Example: 30m idle → 5m grace → 20m activity window +The goal is to ensure `gracePeriod + activityWindow + safetyBuffer <= idleTimeout`, so that: +- A new process gets grace period protection immediately +- An interactive process has enough time to show activity +- There's a safety buffer before the workspace actually idles When **workspace idle timeout is unavailable or disabled** (`-1`): - `gracePeriod`: `5m` - `activityWindow`: `25m` -#### Minimum Values (enforced even for very short idle timeouts): -- `gracePeriod`: At least `1m` -- `activityWindow`: At least `2m` -- `checkPeriod`: At least `10` seconds +### Minimum Values (enforced even for very short idle timeouts) + +| Parameter | Minimum | +|-----------|---------| +| `gracePeriod` | `1m` | +| `activityWindow` | `2m` | +| `checkPeriod` | `10s` | + +### How Defaults, Env Vars, and `.noidle` Interact -**User-specified values always take priority** - smart defaults only apply to unspecified fields. +For each timing parameter, the resolution order is: -### How It Works +1. Start with the **adaptive default** (calculated from idle timeout, or fixed if idle timeout unavailable) +2. If the parameter is specified in `.noidle`, use the `.noidle` value instead +3. If an admin env var is set, enforce it as a **ceiling**: if the resolved value from steps 1-2 exceeds the env var, clamp it down + +The final value and its source are always logged (see [Logging](#logging)). + +### Note on Time Formats + +All time settings (`checkPeriod`, `activityWindow`, `gracePeriod`, `maxProcessAge`) accept: +- Duration strings: `6h`, `30m`, `21600s`, `6h30m` +- Plain integers: `21600` (treated as seconds) +- Invalid values log a warning and use the calculated or fixed default + +## How It Works (Detail) 1. **User Process Detection**: Only watches processes with TTY that are children of user terminals (filters out system processes automatically) -2. **Always-Ignored Check**: Skips passive monitoring tools (`tail`, `watch`, `top`, `htop`) +2. **Always-Ignored Check**: Skips passive monitoring tools (`tail`, `watch`, `top`, `htop`) 3. **Safety Limit**: Processes older than `maxProcessAge` (default 6h) don't prevent idling - protects against hung/forgotten/misconfigured processes -4. **Grace Period**: All user processes < 5 minutes old prevent idling (gives builds time to start) +4. **Grace Period**: All user processes younger than `gracePeriod` prevent idling (gives builds time to start) 5. **Interactive Detection** (after grace period): - **Configured commands**: Use their `interactive` setting - - **Unconfigured commands**: Auto-detect (foreground + has read from TTY → interactive, otherwise → work process) + - **Unconfigured commands**: Auto-detect (foreground + has read from TTY - interactive, otherwise - work process) 6. **Activity Checking**: Interactive processes only prevent idling if user input detected within `activityWindow` -**Note on time formats**: All time settings (`checkPeriod`, `activityWindow`, `gracePeriod`, `maxProcessAge`) accept: -- Duration strings: `6h`, `30m`, `21600s`, `6h30m` -- Plain integers: `21600` (treated as seconds) -- Invalid values log a warning and use the calculated or fixed default - ### Configuration Validation The CLI Watcher validates your configuration and warns about potential issues **without changing your specified values**: -**Warnings you might see**: ``` WARN: activityWindow (35m) exceeds workspace idle timeout (30m), may not work as expected WARN: gracePeriod (25m) is very close to workspace idle timeout (30m) @@ -373,8 +494,6 @@ WARN: Workspace idle timeout (8m) is very short, using minimum activity window ( WARN: Both 'checkPeriod' (30s) and deprecated 'checkPeriodSeconds' (45) are set - using 'checkPeriod' value ``` -These warnings help you identify misconfigurations but **your specified values are always respected**. - ## Activity Detection ### Interactive Process Detection (`auto` mode) @@ -386,8 +505,8 @@ A process is considered interactive if: - Has **ever read from its TTY** (TTY access time is after process start time) This detects: -- ✅ **Interactive**: `vim`, `python3` (REPL), `node` (REPL), `less` → Check for recent user input -- ✅ **Work**: `./compile.sh`, `go build`, `npm run build` → Always prevent idling +- **Interactive**: `vim`, `python3` (REPL), `node` (REPL), `less` - Check for recent user input +- **Work**: `./compile.sh`, `go build`, `npm run build` - Always prevent idling ### Activity Monitoring @@ -397,19 +516,19 @@ For interactive processes, recent activity is detected by monitoring **TTY Acces - Process prevents idling if Atime is within the `activityWindow` **Examples**: -- `claude` actively used → Prevents idling ✅ -- `claude` idle for 30 minutes → Doesn't prevent idling ✅ -- `vim` with active typing → Prevents idling ✅ -- `vim` left open but untouched → Doesn't prevent idling after activity window ✅ -- `go build` running → Always prevents idling ✅ -- Background `node` (VS Code) → Skipped (system process) ✅ +- `claude` actively used - Prevents idling +- `claude` idle for 30 minutes - Doesn't prevent idling +- `vim` with active typing - Prevents idling +- `vim` left open but untouched - Doesn't prevent idling after activity window +- `go build` running - Always prevents idling +- Background `node` (VS Code) - Skipped (system process) ## Always-Ignored Commands The following commands are globally excluded and will NEVER prevent workspace idling, even if explicitly configured or detected as user processes: - `tail` - Log file monitoring -- `watch` - Repeated command execution monitoring +- `watch` - Repeated command execution monitoring - `top` - Process monitoring - `htop` - Enhanced process monitoring @@ -420,7 +539,7 @@ These are passive monitoring tools that don't indicate active work. ### Long-Running Deployments (Override Auto-Detection) ```yaml -# Optional: Force these to always prevent idling (skip auto-detection) +# .noidle watchedCommands: - helm - kubectl @@ -432,9 +551,9 @@ These always prevent idling during deployment operations, even if auto-detection ### Interactive Development with AI (Custom Activity Window) ```yaml -activityWindow: 300 # Override global default (25min) to 5 minutes +# .noidle +activityWindow: 300 # Override global default to 5 minutes -# Optional: Force claude to be interactive (it would likely auto-detect correctly anyway) watchedCommands: - name: claude interactive: true @@ -442,101 +561,190 @@ watchedCommands: Workspace stays alive during active Claude Code sessions, but idles if left idle for 5+ minutes. -**Note**: Without `watchedCommands`, claude would still be watched and likely auto-detected as interactive. This config just makes it explicit and adjusts the activity window. - ### Mixed Workload - Fine-Tuned Control ```yaml -activityWindow: 1500 # Global default: 25 minutes -gracePeriod: 300 # All processes: 5 minute grace period +# .noidle +activityWindow: 25m +gracePeriod: 5m -# Override auto-detection for specific commands only when needed watchedCommands: - # Force deployment tools to always prevent idling - helm - kubectl - - # Force claude interactive with custom activity window - name: claude interactive: true - - # Let vim auto-detect (would likely work the same without this entry) - name: vim interactive: auto - - # Force npm to always prevent idling (in case auto-detection misclassifies) - name: npm interactive: false ``` -**Remember**: All user processes are watched. This config just overrides auto-detection for specific commands. +## Logging -## Environment Variables +### Startup: Admin Config Summary -- `CLI_WATCHER_CONFIG`: Override config file path -- `PROJECT_SOURCE`: Starting point for upward `.noidle` search -- `PROJECTS_ROOT`: Stop point for upward `.noidle` search (defaults to `/`) +At startup, the watcher logs all admin env var values: -## Logging +``` +CLI Watcher: Admin config from environment: +CLI Watcher: CLI_ACTIVITY_TRACKER_ENABLED = true +CLI Watcher: CLI_ACTIVITY_TRACKER_CHECK_PERIOD not set +CLI Watcher: CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW = 15m0s +CLI Watcher: CLI_ACTIVITY_TRACKER_GRACE_PERIOD not set +CLI Watcher: CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE not set +CLI Watcher: CLI_ACTIVITY_TRACKER_VERBOSE not set (default: false) +``` + +### Config Load: Resolved Values with Source + +Every parameter is logged with its final value and source. This makes it easy to understand why a particular value is in effect. + +**Env var used directly (no `.noidle` override):** +``` +CLI Watcher: 'enabled' = true (from CLI_ACTIVITY_TRACKER_ENABLED) +CLI Watcher: 'activityWindow' = 15m0s (from CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW) +CLI Watcher: 'checkPeriod' = 1m0s (default) +``` -The watcher logs its activity at INFO level: +**`.noidle` value accepted (stricter than admin ceiling):** +``` +CLI Watcher: 'activityWindow' = 10m0s (from .noidle; within admin limit 15m0s) +``` + +**`.noidle` value rejected (exceeds admin ceiling):** +``` +CLI Watcher: 'activityWindow' = 15m0s (admin limit; .noidle value 30m0s rejected — exceeds admin ceiling) +``` + +**`.noidle` `enabled` deprecated:** +``` +CLI Watcher: 'enabled' = true (from CLI_ACTIVITY_TRACKER_ENABLED; .noidle 'enabled' is deprecated — admin-controlled) +CLI Watcher: 'enabled' = false (from CLI_ACTIVITY_TRACKER_ENABLED; .noidle 'enabled: true' rejected — deprecated, admin-controlled) +CLI Watcher: 'enabled' = false (default; .noidle 'enabled: true' rejected — deprecated, use CLI_ACTIVITY_TRACKER_ENABLED env var) +``` + +**Default used (no env var, no `.noidle`):** +``` +CLI Watcher: 'enabled' = false (default) +CLI Watcher: 'activityWindow' = 25m0s (default) +``` + +### Runtime: Activity Detection ``` -CLI Watcher: Started CLI Watcher: Config reloaded from /home/user/.noidle CLI Watcher: Watching ALL user processes with 3 explicit override(s): CLI Watcher: - helm (mode: non-interactive (always active)) CLI Watcher: - claude (mode: interactive (activity check)) CLI Watcher: - vim (mode: auto-detect TTY) -CLI Watcher: Detection period: 30 seconds -CLI Watcher: Activity window: 1500 seconds -CLI Watcher: Grace period: 300 seconds +CLI Watcher: Detection period: 30s +CLI Watcher: Activity window: 15m0s +CLI Watcher: Grace period: 4m30s +CLI Watcher: Max process age: 6h0m0s (safety limit) CLI Watcher: Detected CLI command: helm — reporting activity tick ``` -**Note**: The log shows configured overrides, but ALL user processes are monitored. - Use DEBUG level for detailed process scanning: ``` CLI Watcher: Process claude (PID 12345) has recent activity +CLI Watcher: Process vi (PID 12345) found but no recent activity ``` -## Migration Guide +### Verbose Activity Logging -### From Simple String List +By default, detailed activity-detection reasoning (which process was detected, why it does or doesn't prevent idling, interactive/auto-detection decisions) is logged at Debug level. Enabling global `LOG_LEVEL=debug` shows this, but also produces debug output from every other component in che-machine-exec. + +Set `CLI_ACTIVITY_TRACKER_VERBOSE=true` to promote just the CLI Watcher's activity-detection messages to Info level, without touching the global log level: -**Before:** -```yaml -watchedCommands: - - helm - - claude +``` +CLI Watcher: Detected CLI command: helm — reporting activity tick +CLI Watcher: Process vi (PID 12345) auto-detected as interactive (default policy) +CLI Watcher: Process vi (PID 12345) is interactive with recent activity (default policy) +CLI Watcher: Process npm (PID 12346) is in config ignored list, skipping ``` -**After (to enable activity checking for claude):** -```yaml -activityWindow: 300 # Set global activity window +## Upgrading from Previous Versions -watchedCommands: - - helm # Still simple string - always active (or auto-detected after grace period) - - name: claude - interactive: true # Force interactive mode for explicit control -``` +### Breaking Changes + +#### 1. `enabled` Field in `.noidle` is Deprecated + +**Before**: The `enabled: true` field in `.noidle` was the only way to enable the CLI Watcher. + +**After**: Enablement is controlled exclusively by the `CLI_ACTIVITY_TRACKER_ENABLED` environment variable (or its default). The `.noidle` `enabled` field is **ignored** with a deprecation warning logged. + +**Migration**: Ask your cluster administrator to set `CLI_ACTIVITY_TRACKER_ENABLED=true` in the DevWorkspace configuration. + +#### 2. Timing Parameters Have Admin Ceilings + +**Before**: `.noidle` timing values were always used as-is. + +**After**: If an administrator sets timing env vars (`CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW`, etc.), `.noidle` values can only be **stricter** (shorter). Looser values are clamped to the admin ceiling with a warning. + +#### 3. ALL User Processes Are Watched by Default + +**Before (older versions)**: Only commands listed in `watchedCommands` were monitored. -**Note**: With the new implementation, even unconfigured commands are automatically watched and intelligently classified as interactive or work processes after the grace period. Explicit configuration is only needed to override the auto-detection. +**After**: **ALL user processes with TTY are monitored automatically**. `watchedCommands` now **overrides auto-detection** for specific commands (not required to enable watching). `tail`, `watch`, `top`, `htop` are **always ignored**. + +### Impact on Your Workspace + +1. **Workspaces may stay active longer** - processes that were previously ignored (shells, scripts, REPLs) now prevent idling +2. **Commands in `watchedCommands` may behave differently**: + - If you configured `watch`, `top`, or `htop` - now ignored with a warning + - If you only listed specific commands - other user processes are now also monitored +3. **Auto-detection may differ from your expectations** - interactive processes (vim, python REPL) only prevent idling when actively used + +### Migration Steps + +**If you have an existing `.noidle` configuration:** + +1. **Remove `enabled: true`** - enablement is now admin-controlled via `CLI_ACTIVITY_TRACKER_ENABLED` env var +2. **Review your `watchedCommands` list** - remove `watch`, `top`, `htop` (always ignored now) +3. **Check timing values** - if admin ceilings are set, your values may be clamped + +**If you're an administrator enabling CLI Watcher for the first time:** + +1. Set `CLI_ACTIVITY_TRACKER_ENABLED=true` in DevWorkspace env vars +2. Optionally set timing ceilings to enforce policy bounds +3. Users can create `.noidle` files to tune within your bounds + +### Verification + +After updating: + +1. Check logs for deprecation warnings about `.noidle` `enabled` field +2. Check logs for ceiling enforcement messages (rejected/accepted overrides) +3. Monitor workspace idle timeout behavior +4. Use `LOG_LEVEL=debug` to see which processes are detected and classified + +### Rollback + +If the new behavior doesn't suit your workflow: + +1. Use `ignoredCommands` to exclude unwanted processes +2. Set explicit `interactive` modes in `watchedCommands` to override auto-detection +3. Contact your platform administrator if workspace idle policies need adjustment ## Deployment Requirements ### Filesystem Access Time (atime) Dependency -**CRITICAL**: Interactive process detection depends on filesystem access time (atime) updates for TTY devices. +**CRITICAL**: Interactive-process classification and activity-freshness tracking both prefer filesystem access time (atime) updates for TTY devices as their primary signal. + +**Problem**: If the `devpts` filesystem (backing `/dev/pts/*`, i.e. workspace terminals) is mounted with `noatime`: +- TTY access times never update, regardless of real terminal activity +- Both interactive-process classification and "is this process still active" checks fall back to less precise, atime-independent detection (see below) -**Problem**: If containers or systems run on filesystems mounted with `noatime` or `relatime`: -- TTY access times won't update when users interact with terminals -- Interactive processes will appear "idle" even when actively used -- Workspaces may shutdown unexpectedly during active terminal sessions +`relatime` (the default on most systems) is generally *not* a problem — atime can lag real activity by up to tens of seconds under heavy load, but it does track it. `noatime` is the actual failure mode: atime freezes at whatever value it had before, forever. + +**Automatic Detection**: CLI Watcher checks `/proc/mounts` for the `devpts` mount options once at startup and logs a warning if `noatime` is detected: +``` +CLI Watcher: devpts (/dev/pts) is mounted with 'noatime' — TTY access-time tracking is disabled, so interactive-process activity will be detected via a CPU-usage fallback instead of keystroke timing (coarser; may keep workspaces alive slightly longer than expected) +``` -**Verification**: Check if `/dev/pts` is mounted with atime support: +**Manual Verification**: Check if `/dev/pts` is mounted with atime support: ```bash # Check mount options for devpts filesystem mount | grep devpts @@ -556,29 +764,155 @@ devpts on /dev/pts type devpts (rw,nosuid,noexec,noatime,gid=5,mode=620,ptmxmode sudo mount -o remount,relatime /dev/pts ``` -**Robust Fallback Detection**: When atime is unavailable or unreliable, the CLI Watcher automatically uses sophisticated alternative detection methods: +**Fallback Detection**: When atime hasn't advanced past a process's start time (the sign that it's genuinely unusable — `noatime`, or a TTY never read from at all), CLI Watcher substitutes two independent, atime-free signals for the two different questions it needs to answer: -1. **Process State Analysis** - Analyzes if process is sleeping (waiting for input) -2. **Enhanced Wait Channel Analysis** - Detects specific input-waiting syscalls: - - `poll_schedule_timeout` - polling with timeout (interactive pattern) +1. **Classification** ("is this the kind of process that waits for input at all?") — checks the process's wait channel (`wchan`) for patterns consistent with an input-driven event loop: + - `poll_schedule_timeout` - polling with timeout (interactive pattern) - `pipe_wait` - waiting on pipe input - `unix_stream_read_generic` - reading from socket - `select`, `ep_poll` - event-driven input waiting -3. **File Descriptor Activity** - Monitors recent TTY file descriptor usage -**Scoring System**: Multiple detection signals are combined with a scoring threshold to reliably identify interactive processes, even without atime support. +2. **Activity freshness** ("is this already-classified-interactive process still being used right now?") — tracks CPU time (`utime`+`stime` from `/proc//stat`) sampled once per check cycle. If a process has consumed any CPU since it was last observed, it's treated as active. -**Automatic Fallback**: No configuration needed - the system automatically detects atime issues and switches to alternative methods with debug logging. +The CPU-usage signal can't distinguish "the user typed something" from "the process did something on its own" (e.g. background timers), so it's coarser than atime — but unlike atime under `noatime`, it doesn't get stuck reporting "never active" forever. **Symptoms Indicating Fallback Mode**: -- Debug logs show: "TTY atime for PID X unavailable or unreliable, using fallback detection" -- Debug logs show: "PID X detected as interactive via fallback (score: N, wchan: Y)" +- Startup log: `"devpts (...) is mounted with 'noatime' ..."` (see Automatic Detection above) +- `"CLI Watcher: TTY atime for PID X unavailable or unreliable, using fallback detection"` — classification fallback triggered +- `"CLI Watcher: PID X detected as interactive via fallback (wchan: Y)"` — classified interactive via wchan +- `"CLI Watcher: TTY atime for PID X unavailable or unreliable, using CPU-activity fallback"` — freshness fallback triggered +- `"CLI Watcher: PID X CPU-activity fallback: recent=... (last active ... ago)"` — freshness fallback's verdict (set `CLI_ACTIVITY_TRACKER_VERBOSE=true` to see this at Info level instead of Debug — see [Verbose Activity Logging](#verbose-activity-logging)) -**Result**: Interactive detection remains highly reliable even on `noatime` filesystems, though atime support is still preferred for optimal performance. +**Result**: Interactive detection remains reliable even on `noatime` filesystems, though atime support is still preferable for both precision (per-keystroke timing vs. per-check-cycle CPU sampling) and correctness (CPU-based freshness can't tell genuine user input from unrelated background work). ## Testing -### Monitoring Activity Ticks +### Testing Administrator Configuration (Environment Variables) + +These scenarios verify that admin env vars correctly control CLI Watcher behavior, enforce ceilings, and produce the expected log output. + +#### Scenario A1: Enable CLI Watcher via Env Var Only (No `.noidle` File) + +**Setup**: No `.noidle` file exists anywhere. + +```bash +# Set env vars before starting che-machine-exec +export CLI_ACTIVITY_TRACKER_ENABLED=true + +# In a DevWorkspace, set in the container spec: +# env: +# - name: CLI_ACTIVITY_TRACKER_ENABLED +# value: "true" +``` + +**Expected startup logs**: +``` +CLI Watcher: Admin config from environment: +CLI Watcher: CLI_ACTIVITY_TRACKER_ENABLED = true +CLI Watcher: CLI_ACTIVITY_TRACKER_CHECK_PERIOD not set +CLI Watcher: CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW not set +CLI Watcher: CLI_ACTIVITY_TRACKER_GRACE_PERIOD not set +CLI Watcher: CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE not set +``` + +**Expected config resolution logs** (no `.noidle` file): +``` +CLI Watcher: Config file not found, waiting for it to appear... +CLI Watcher: 'enabled' = true (from CLI_ACTIVITY_TRACKER_ENABLED) +CLI Watcher: 'checkPeriod' = 1m0s (default) +CLI Watcher: 'activityWindow' = 20m30s (default) +CLI Watcher: 'gracePeriod' = 4m30s (default) +CLI Watcher: 'maxProcessAge' = 6h0m0s (default) +``` + +**Verify**: The watcher is active and scanning processes despite no `.noidle` file. + +#### Scenario A2: Admin Disables CLI Watcher, User `.noidle` Says `enabled: true` + +**Setup**: Create a `.noidle` file: +```yaml +enabled: true +activityWindow: 20m +``` + +```bash +export CLI_ACTIVITY_TRACKER_ENABLED=false +``` + +**Expected config resolution logs**: +``` +CLI Watcher: 'enabled' = false (from CLI_ACTIVITY_TRACKER_ENABLED; .noidle 'enabled: true' rejected — deprecated, admin-controlled) +CLI Watcher: 'activityWindow' = 20m0s (from .noidle) +... +``` + +**Verify**: The watcher is NOT scanning processes despite `.noidle` having `enabled: true`. + +#### Scenario A3: Admin Sets Timing Ceilings, User `.noidle` Exceeds Them + +**Setup**: Create a `.noidle` file: +```yaml +activityWindow: 30m +gracePeriod: 10m +checkPeriod: 120 +maxProcessAge: 12h +``` + +```bash +export CLI_ACTIVITY_TRACKER_ENABLED=true +export CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW=15m +export CLI_ACTIVITY_TRACKER_GRACE_PERIOD=3m +export CLI_ACTIVITY_TRACKER_CHECK_PERIOD=45s +export CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE=4h +``` + +**Expected config resolution logs**: +``` +CLI Watcher: 'enabled' = true (from CLI_ACTIVITY_TRACKER_ENABLED; .noidle 'enabled' is deprecated — admin-controlled) +CLI Watcher: 'checkPeriod' = 45s (admin limit; .noidle value 2m0s rejected — exceeds admin ceiling) +CLI Watcher: 'activityWindow' = 15m0s (admin limit; .noidle value 30m0s rejected — exceeds admin ceiling) +CLI Watcher: 'gracePeriod' = 3m0s (admin limit; .noidle value 10m0s rejected — exceeds admin ceiling) +CLI Watcher: 'maxProcessAge' = 4h0m0s (admin limit; .noidle value 12h0m0s rejected — exceeds admin ceiling) +``` + +**Verify**: All timing params are clamped to admin values, not `.noidle` values. + +#### Scenario A4: Admin Sets Ceilings, User `.noidle` Is Stricter + +**Setup**: Create a `.noidle` file: +```yaml +activityWindow: 10m +gracePeriod: 2m +``` + +```bash +export CLI_ACTIVITY_TRACKER_ENABLED=true +export CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW=15m +export CLI_ACTIVITY_TRACKER_GRACE_PERIOD=5m +``` + +**Expected config resolution logs**: +``` +CLI Watcher: 'enabled' = true (from CLI_ACTIVITY_TRACKER_ENABLED) +CLI Watcher: 'activityWindow' = 10m0s (from .noidle; within admin limit 15m0s) +CLI Watcher: 'gracePeriod' = 2m0s (from .noidle; within admin limit 5m0s) +``` + +**Verify**: User's stricter values are accepted. + +#### Scenario A5: Hot-Reload `.noidle` While Running + +**Setup**: Start with `CLI_ACTIVITY_TRACKER_ENABLED=true` and `CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW=15m`. + +1. Create a `.noidle` file with `activityWindow: 10m` - observe "accepted" log +2. Edit it to `activityWindow: 30m` - observe "rejected" log on next check cycle +3. Delete the `.noidle` file - observe config reverts to env var values + +**Verify**: Changes take effect on the next check cycle without restart. + +### Testing User Configuration (`.noidle` File) + +#### Monitoring Activity Ticks To watch CLI watcher activity ticks in real-time in a DevWorkspace environment, open a terminal and run: @@ -592,19 +926,6 @@ This will show continuous log output including: - Detected CLI commands and activity ticks - Process scanning debug messages (if `LOG_LEVEL=debug`) -Example output: -``` -CLI Watcher: Started -CLI Watcher: Config reloaded from /projects/.noidle -CLI Watcher: Watching 3 command(s): -CLI Watcher: - sleep (mode: non-interactive (always active)) -CLI Watcher: - vi (mode: auto-detect TTY, activity window: 120s) -CLI Watcher: Detection period is 15 seconds -CLI Watcher: Detected CLI command: sleep — reporting activity tick -``` - -### Test Scenarios - #### Available Commands in UBI9 Go-Toolset First, verify what commands are available in your dev container: @@ -621,11 +942,10 @@ Typically available: - **Interactive**: `vi`, `less`, `more`, `bash`, `sh` - **Non-interactive**: `sleep`, `ping`, `curl`, `wget`, `yes` -#### Scenario 1: Non-Interactive Long-Running Commands +#### Scenario U1: Non-Interactive Long-Running Commands **Test Config** (`/tmp/.noidle.test`): ```yaml -enabled: true checkPeriod: 15 watchedCommands: @@ -635,7 +955,8 @@ watchedCommands: **Test Steps**: ```bash -export CLI_WATCHER_CONFIG=/tmp/.noidle.test +export CLI_ACTIVITY_TRACKER_ENABLED=true +export CLI_ACTIVITY_TRACKER_CONFIG=/tmp/.noidle.test # Start a long-running background process (no TTY) sleep 1800 & @@ -648,11 +969,10 @@ tail -f /checode/entrypoint-logs.txt **Cleanup**: `pkill sleep` -#### Scenario 2: Interactive Command with Activity Tracking +#### Scenario U2: Interactive Command with Activity Tracking **Test Config** (`/tmp/.noidle.interactive`): ```yaml -enabled: true checkPeriod: 15 activityWindow: 120 # 2 minutes for easy testing @@ -663,7 +983,8 @@ watchedCommands: **Test Steps**: ```bash -export CLI_WATCHER_CONFIG=/tmp/.noidle.interactive +export CLI_ACTIVITY_TRACKER_ENABLED=true +export CLI_ACTIVITY_TRACKER_CONFIG=/tmp/.noidle.interactive # Terminal 1: Watch logs tail -f /checode/entrypoint-logs.txt @@ -675,13 +996,12 @@ vi /tmp/testfile.txt # Stop typing for 3+ minutes - activity ticks should stop ``` -#### Scenario 3: Auto-Detection of Interactive vs Work Processes +#### Scenario U3: Auto-Detection of Interactive vs Work Processes **Purpose**: Verify that the watcher correctly distinguishes between interactive CLIs (vim, REPLs) and work processes (builds, scripts) without explicit configuration. **Test Config** (`/tmp/.noidle.autodetect`): ```yaml -enabled: true checkPeriod: 15 activityWindow: 120 # 2 minutes for easy testing gracePeriod: 1m # Short grace period for faster testing @@ -691,7 +1011,8 @@ gracePeriod: 1m # Short grace period for faster testing **Test Steps**: ```bash -export CLI_WATCHER_CONFIG=/tmp/.noidle.autodetect +export CLI_ACTIVITY_TRACKER_ENABLED=true +export CLI_ACTIVITY_TRACKER_CONFIG=/tmp/.noidle.autodetect # Terminal 1: Watch logs tail -f /checode/entrypoint-logs.txt @@ -707,8 +1028,8 @@ sleep 300 ``` **Expected behavior**: -- `vi` detected as **interactive** (foreground + reads from TTY) → Only ticks when typing -- `sleep` detected as **work process** (not interactive) → Always ticks while running +- `vi` detected as **interactive** (foreground + reads from TTY) - Only ticks when typing +- `sleep` detected as **work process** (not interactive) - Always ticks while running - Grace period (1min): Both prevent idling immediately when started **Debugging**: Set `LOG_LEVEL=debug` to see detailed detection: @@ -718,11 +1039,10 @@ CLI Watcher: Process vi (PID 12345) has recent activity CLI Watcher: Process sleep (PID 12346) auto-detected as work process ``` -#### Scenario 4: Excluded Commands (Negative Test) +#### Scenario U4: Excluded Commands (Negative Test) **Test Config** (`/tmp/.noidle.exclusion`): ```yaml -enabled: true checkPeriod: 10 watchedCommands: @@ -751,13 +1071,14 @@ CLI Watcher: Process vi (PID 12345) has recent activity CLI Watcher: Process vi (PID 12345) found but no recent activity ``` +**Scoped alternative**: To see CLI Watcher activity-detection details without enabling debug logging application-wide, set `CLI_ACTIVITY_TRACKER_VERBOSE=true` instead. This promotes only the CLI Watcher's own detection/reasoning messages to Info level. + ### Quick Test Setup Create a test configuration file: ```yaml # /tmp/.noidle.quicktest -enabled: true checkPeriod: 10 activityWindow: 120 # 2 minutes for easy testing watchedCommands: @@ -772,7 +1093,8 @@ watchedCommands: 2. **Start server with test config**: ```bash - export CLI_WATCHER_CONFIG=/tmp/.noidle.quicktest + export CLI_ACTIVITY_TRACKER_ENABLED=true + export CLI_ACTIVITY_TRACKER_CONFIG=/tmp/.noidle.quicktest ``` Then run devfile command: `start-exec-server` @@ -785,7 +1107,7 @@ watchedCommands: ```bash # Terminal 1: Non-interactive (always active) sleep 600 & - + # Terminal 2: Interactive (activity tracked) vi /tmp/test.txt ``` @@ -802,10 +1124,10 @@ watchedCommands: | Command | Mode | Has TTY? | Active I/O? | Prevents Idling? | |---------|------|----------|-------------|------------------| -| `sleep 3600 &` | default (no) | No | N/A | ✅ Always | -| `vi file.txt` (typing) | auto | Yes | Yes | ✅ Yes | -| `vi file.txt` (idle) | auto | Yes | No | ❌ No (after window) | -| `tail -f file` | any | any | any | ❌ Never (excluded) | +| `sleep 3600 &` | default (no) | No | N/A | Always | +| `vi file.txt` (typing) | auto | Yes | Yes | Yes | +| `vi file.txt` (idle) | auto | Yes | No | No (after window) | +| `tail -f file` | any | any | any | Never (excluded) | ### Developer Testing @@ -822,7 +1144,7 @@ go test ./timeout -cover ``` **Note on test coverage:** -- **Unit tests cover pure functions** (parsing, configuration, validation, defaults, YAML unmarshaling) +- **Unit tests cover pure functions** (parsing, configuration, validation, defaults, YAML unmarshaling, env var loading, ceiling enforcement) - **Core detection logic is untested** (process tree walking, TTY analysis, interactive process detection, `isWatchedProcessRunning`, `isUserInitiatedProcess`) **Why core detection logic requires manual testing:** diff --git a/timeout/cli-watcher.go b/timeout/cli-watcher.go index e40034f3..ee9d5902 100644 --- a/timeout/cli-watcher.go +++ b/timeout/cli-watcher.go @@ -68,6 +68,19 @@ const ( SafetyBufferPercent = 0.2 // Or 20% of idle timeout, whichever is smaller ) +// Environment variable names for admin-level CLI Watcher configuration +const ( + EnvCliWatcherEnabled = "CLI_ACTIVITY_TRACKER_ENABLED" + EnvCliWatcherCheckPeriod = "CLI_ACTIVITY_TRACKER_CHECK_PERIOD" + EnvCliWatcherActivityWindow = "CLI_ACTIVITY_TRACKER_ACTIVITY_WINDOW" + EnvCliWatcherGracePeriod = "CLI_ACTIVITY_TRACKER_GRACE_PERIOD" + EnvCliWatcherMaxProcessAge = "CLI_ACTIVITY_TRACKER_MAX_PROCESS_AGE" + EnvCliWatcherVerbose = "CLI_ACTIVITY_TRACKER_VERBOSE" +) + +// DefaultCliWatcherEnabled is the default for CLI_ACTIVITY_TRACKER_ENABLED (flip to true when ready for general rollout) +const DefaultCliWatcherEnabled = false + // ttyCache holds cached TTY device information to reduce redundant filesystem operations type ttyCache struct { path string // TTY device path (e.g., "/dev/pts/1") @@ -146,6 +159,19 @@ type cliWatcherConfig struct { _activityWindowParsed time.Duration `json:"-"` _gracePeriodParsed time.Duration `json:"-"` _maxProcessAgeParsed time.Duration `json:"-"` + _fromFile bool `json:"-"` // true only if loaded from an actual .noidle file + _verbose bool `json:"-"` // resolved from CLI_ACTIVITY_TRACKER_VERBOSE +} + +// cliWatcherEnvConfig holds admin-level configuration from environment variables. +// Pointer fields: nil = not set by admin, non-nil = admin-enforced ceiling. +type cliWatcherEnvConfig struct { + enabled *bool + checkPeriod *time.Duration + activityWindow *time.Duration + gracePeriod *time.Duration + maxProcessAge *time.Duration + verbose *bool } // Watcher monitors CLI processes and invokes a tick callback when active ones are found @@ -159,6 +185,7 @@ type cliWatcher struct { tickFunc func() // Immutable after construction (safe to read without lock) myPID string // Immutable after construction (safe to read without lock) idleTimeout time.Duration // Immutable after construction (safe to read without lock) + envConfig cliWatcherEnvConfig // Immutable after Start() (safe to read without lock) } // Commands that should NEVER prevent workspace idling (passive monitoring tools) @@ -258,6 +285,54 @@ func getPlatformDefaultClockTicks() int64 { } } +func loadEnvConfig() cliWatcherEnvConfig { + var cfg cliWatcherEnvConfig + + if v, ok := os.LookupEnv(EnvCliWatcherEnabled); ok { + if b, err := strconv.ParseBool(v); err == nil { + cfg.enabled = &b + } else { + logrus.Errorf("CLI Watcher: Invalid value '%s' for %s, expected boolean", v, EnvCliWatcherEnabled) + } + } + + parseDurationEnv := func(envName string, target **time.Duration) { + if v, ok := os.LookupEnv(envName); ok && len(v) > 0 { + d := parseDuration(v, envName, 0) + if d > 0 { + *target = &d + } else { + logrus.Errorf("CLI Watcher: Invalid value '%s' for %s, expected positive duration (e.g. 30s, 5m, 1h)", v, envName) + } + } + } + + parseDurationEnv(EnvCliWatcherCheckPeriod, &cfg.checkPeriod) + parseDurationEnv(EnvCliWatcherActivityWindow, &cfg.activityWindow) + parseDurationEnv(EnvCliWatcherGracePeriod, &cfg.gracePeriod) + parseDurationEnv(EnvCliWatcherMaxProcessAge, &cfg.maxProcessAge) + + if v, ok := os.LookupEnv(EnvCliWatcherVerbose); ok { + if b, err := strconv.ParseBool(v); err == nil { + cfg.verbose = &b + } else { + logrus.Errorf("CLI Watcher: Invalid value '%s' for %s, expected boolean", v, EnvCliWatcherVerbose) + } + } + + return cfg +} + +// activityLogf logs CLI Watcher activity-detection details at Info level when verbose +// is true (CLI_ACTIVITY_TRACKER_VERBOSE), otherwise at Debug level. +func activityLogf(verbose bool, format string, args ...interface{}) { + if verbose { + logrus.Infof(format, args...) + } else { + logrus.Debugf(format, args...) + } +} + // New creates a new Watcher with the given config and tick callback func NewCliWatcher(tickFunc func(), idleTimeout time.Duration) *cliWatcher { if tickFunc == nil { @@ -279,8 +354,33 @@ func (w *cliWatcher) Start() { return } w.started = true + w.envConfig = loadEnvConfig() w.mu.Unlock() + logrus.Infof("CLI Watcher: Admin config from environment:") + if w.envConfig.enabled != nil { + logrus.Infof("CLI Watcher: %s = %t", EnvCliWatcherEnabled, *w.envConfig.enabled) + } else { + logrus.Infof("CLI Watcher: %s not set (default: %t)", EnvCliWatcherEnabled, DefaultCliWatcherEnabled) + } + logEnvDuration := func(envName string, val *time.Duration) { + if val != nil { + logrus.Infof("CLI Watcher: %s = %v", envName, *val) + } else { + logrus.Infof("CLI Watcher: %s not set", envName) + } + } + logEnvDuration(EnvCliWatcherCheckPeriod, w.envConfig.checkPeriod) + logEnvDuration(EnvCliWatcherActivityWindow, w.envConfig.activityWindow) + logEnvDuration(EnvCliWatcherGracePeriod, w.envConfig.gracePeriod) + logEnvDuration(EnvCliWatcherMaxProcessAge, w.envConfig.maxProcessAge) + if w.envConfig.verbose != nil { + logrus.Infof("CLI Watcher: %s = %t", EnvCliWatcherVerbose, *w.envConfig.verbose) + } else { + logrus.Infof("CLI Watcher: %s not set (default: false)", EnvCliWatcherVerbose) + } + checkDevptsAtimeSupport() + go func() { var err error w.mu.Lock() @@ -340,7 +440,7 @@ func (w *cliWatcher) Start() { found, name := isWatchedProcessRunning(configSnapshot, w.myPID) if found { - logrus.Debugf("CLI Watcher: Detected CLI command: %s — reporting activity tick", name) + activityLogf(configSnapshot._verbose, "CLI Watcher: Detected CLI command: %s — reporting activity tick", name) if w.tickFunc != nil { w.tickFunc() } @@ -415,11 +515,11 @@ func isWatchedProcessRunning(config *cliWatcherConfig, myPID string) (bool, stri // STEP 1: Check if command is in always-ignored list OR config ignored list if slices.Contains(alwaysIgnoredCommands, cmdName) { - logrus.Debugf("CLI Watcher: Process %s (PID %s) is in always-ignored list, skipping", cmdName, pid) + activityLogf(config._verbose, "CLI Watcher: Process %s (PID %s) is in always-ignored list, skipping", cmdName, pid) continue } if slices.Contains(config.IgnoredCommands, cmdName) { - logrus.Debugf("CLI Watcher: Process %s (PID %s) is in config ignored list, skipping", cmdName, pid) + activityLogf(config._verbose, "CLI Watcher: Process %s (PID %s) is in config ignored list, skipping", cmdName, pid) continue } @@ -450,11 +550,11 @@ func isWatchedProcessRunning(config *cliWatcherConfig, myPID string) (bool, stri } if processAge == 0 { // Can't determine age (getProcessStartTime failed) - give benefit of doubt with grace period - logrus.Debugf("CLI Watcher: Process %s (PID %s) age unknown, applying grace period protection", cmdName, pid) + activityLogf(config._verbose, "CLI Watcher: Process %s (PID %s) age unknown, applying grace period protection", cmdName, pid) return true, cmdName } if processAge < gracePeriod { - logrus.Debugf("CLI Watcher: Process %s (PID %s) in grace period (age: %v), preventing idling", cmdName, pid, processAge) + activityLogf(config._verbose, "CLI Watcher: Process %s (PID %s) in grace period (age: %v), preventing idling", cmdName, pid, processAge) return true, cmdName } @@ -472,7 +572,7 @@ func isWatchedProcessRunning(config *cliWatcherConfig, myPID string) (bool, stri policySource = "default" } - if !applyPolicy(pid, cmdName, mode, config._activityWindowParsed, policySource) { + if !applyPolicy(pid, cmdName, mode, config._activityWindowParsed, policySource, config._verbose) { continue } @@ -499,6 +599,8 @@ type procStat struct { ppid string // Parent PID (field 4) pgrp int // Process group ID (field 5) tpgid int // Foreground process group of TTY (field 8) + utimeTicks int64 // CPU time in user mode, clock ticks (field 14) + stimeTicks int64 // CPU time in kernel mode, clock ticks (field 15) startTicks int64 // Process start time in clock ticks (field 22) } @@ -518,7 +620,11 @@ func parseProcStat(pid string) (*procStat, error) { if err != nil { return nil, err } - defer file.Close() + defer func() { + if closeErr := file.Close(); closeErr != nil { + logrus.Debugf("CLI Watcher: Failed to close %s: %v", statPath, closeErr) + } + }() // Read with size limit data := make([]byte, maxStatFileSize) @@ -564,6 +670,16 @@ func parseProcStat(pid string) (*procStat, error) { return nil, fmt.Errorf("failed to parse tpgid") } + // Field 14 (index 11): utime — CPU time in user mode (clock ticks) + if n, err := fmt.Sscanf(fields[11], "%d", &stat.utimeTicks); err != nil || n != 1 { + return nil, fmt.Errorf("failed to parse utime") + } + + // Field 15 (index 12): stime — CPU time in kernel mode (clock ticks) + if n, err := fmt.Sscanf(fields[12], "%d", &stat.stimeTicks); err != nil || n != 1 { + return nil, fmt.Errorf("failed to parse stime") + } + // Field 22 (index 19): starttime (clock ticks since boot) // Validate > 0: starttime=0 is invalid (would mean process started at boot time), // and negative values indicate corrupted /proc data @@ -577,38 +693,38 @@ func parseProcStat(pid string) (*procStat, error) { // applyPolicy applies the interactive policy for a command // Returns true if process should prevent idling, false otherwise // Unified function handling both configured and default policies -func applyPolicy(pid, cmdName string, mode InteractiveMode, activityWindow time.Duration, policySource string) bool { +func applyPolicy(pid, cmdName string, mode InteractiveMode, activityWindow time.Duration, policySource string, verbose bool) bool { // Determine if process is interactive var checkActivity bool switch mode { case InteractiveModeAuto: // Auto-detect: use foreground + TTY read analysis - checkActivity = isInteractiveProcess(pid) + checkActivity = isInteractiveProcess(pid, verbose) if checkActivity { - logrus.Debugf("CLI Watcher: Process %s (PID %s) auto-detected as interactive (%s policy)", cmdName, pid, policySource) + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) auto-detected as interactive (%s policy)", cmdName, pid, policySource) } else { - logrus.Debugf("CLI Watcher: Process %s (PID %s) auto-detected as work process (%s policy)", cmdName, pid, policySource) + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) auto-detected as work process (%s policy)", cmdName, pid, policySource) } case InteractiveModeTrue, InteractiveModeYes: // Force interactive mode checkActivity = true - logrus.Debugf("CLI Watcher: Process %s (PID %s) forced interactive (%s policy)", cmdName, pid, policySource) + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) forced interactive (%s policy)", cmdName, pid, policySource) case InteractiveModeFalse, InteractiveModeNo: // Force non-interactive (work) mode checkActivity = false - logrus.Debugf("CLI Watcher: Process %s (PID %s) forced non-interactive (%s policy)", cmdName, pid, policySource) + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) forced non-interactive (%s policy)", cmdName, pid, policySource) } // If interactive, check for recent activity if checkActivity { - if !hasRecentActivity(activityWindow, pid) { - logrus.Debugf("CLI Watcher: Process %s (PID %s) is interactive but no recent activity (%s policy)", cmdName, pid, policySource) + if !hasRecentActivity(activityWindow, pid, verbose) { + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) is interactive but no recent activity (%s policy)", cmdName, pid, policySource) return false } - logrus.Debugf("CLI Watcher: Process %s (PID %s) is interactive with recent activity (%s policy)", cmdName, pid, policySource) + activityLogf(verbose, "CLI Watcher: Process %s (PID %s) is interactive with recent activity (%s policy)", cmdName, pid, policySource) } return true @@ -897,7 +1013,7 @@ func getTTYAtime(pid string) time.Time { // hasEverReadFromTTY checks if the process has ever read from its TTY // NOTE: This depends on filesystem access time (atime) being updated. // On filesystems mounted with 'noatime' or 'relatime', this may not work reliably. -func hasEverReadFromTTY(pid string) bool { +func hasEverReadFromTTY(pid string, verbose bool) bool { startTime := getProcessStartTime(pid) if startTime.IsZero() { return false @@ -914,100 +1030,39 @@ func hasEverReadFromTTY(pid string) bool { } // Atime failed - fall back to alternative detection methods - logrus.Debugf("CLI Watcher: TTY atime for PID %s unavailable or unreliable, using fallback detection", pid) - return hasInteractiveBehaviorFallback(pid) + activityLogf(verbose, "CLI Watcher: TTY atime for PID %s unavailable or unreliable, using fallback detection", pid) + return hasInteractiveBehaviorFallback(pid, verbose) } -// hasInteractiveBehaviorFallback uses alternative methods when atime is unavailable -// Combines: process state analysis, enhanced wchan analysis, and FD analysis -func hasInteractiveBehaviorFallback(pid string) bool { - score := 0 - - // Method #1: Process State Analysis - // Interactive processes are typically sleeping (waiting for input) - if state := getProcessState(pid); state == "S" { - score += 2 // Sleeping = likely waiting for input - } - - // Method #2: Enhanced wchan Analysis (beyond basic TTY read) +// hasInteractiveBehaviorFallback checks whether the process is blocked in a syscall +// pattern consistent with waiting for user input, used when TTY atime is unavailable +// or unreliable. +// +// Process state ("S") and /proc//fd's mtime were previously part of a weighted +// score, but both proved non-specific: any blocking syscall reports state "S", and +// /proc//fd's mtime is set once when the fd table is created (effectively process +// start time) and never updates again for processes that don't open/close fds +// afterward — so it really measured "process age < 5 minutes," not activity. Verified +// on a live workspace: a non-interactive `sleep` was misclassified as interactive +// during its first ~5 minutes solely because of this. +func hasInteractiveBehaviorFallback(pid string, verbose bool) bool { wchan := getWaitChannel(pid) - if wchan == "poll_schedule_timeout" || // Polling with timeout (interactive pattern) + isInteractive := wchan == "poll_schedule_timeout" || // Polling with timeout (interactive pattern) wchan == "pipe_wait" || // Waiting on pipe input wchan == "unix_stream_read_generic" || // Reading from socket wchan == "select" || // Select/poll waiting for input - wchan == "ep_poll" { // Epoll waiting (event-driven input) - score += 3 // Strong indicator of waiting for input - } - - // Method #3: File Descriptor Analysis - // Check if stdin is actively connected to TTY - if hasActiveTTYConnection(pid) { - score += 2 - } + wchan == "ep_poll" // Epoll waiting (event-driven input) - // Threshold: score >= 4 indicates interactive behavior - // This is conservative - when in doubt, assume interactive to prevent false negatives - isInteractive := score >= 4 if isInteractive { - logrus.Debugf("CLI Watcher: PID %s detected as interactive via fallback (score: %d, wchan: %s)", pid, score, wchan) + activityLogf(verbose, "CLI Watcher: PID %s detected as interactive via fallback (wchan: %s)", pid, wchan) } return isInteractive } -// getProcessState returns the process state from /proc/[pid]/stat field 3 -func getProcessState(pid string) string { - // Read the raw stat file for state (field 3) - statPath := filepath.Join("/proc", pid, "stat") - data, err := os.ReadFile(statPath) - if err != nil { - return "" - } - - str := string(data) - // Find the last ')' to handle process names with spaces/parens - lastParen := strings.LastIndex(str, ")") - if lastParen == -1 { - return "" - } - - // State is the first field after ')' - fields := strings.Fields(str[lastParen+1:]) - if len(fields) > 0 { - return fields[0] // State (R/S/D/Z/T) - } - return "" -} - -// hasActiveTTYConnection checks if process has active TTY file descriptors -func hasActiveTTYConnection(pid string) bool { - // Check if stdin (fd/0) points to a TTY and is recently accessed - fd0Path := filepath.Join("/proc", pid, "fd", "0") - target, err := os.Readlink(fd0Path) - if err != nil { - return false - } - - // Must be a TTY device - if !strings.HasPrefix(target, "/dev/pts/") && !strings.HasPrefix(target, "/dev/tty") { - return false - } - - // Check if the fd directory itself has been recently modified - // This indicates recent file descriptor activity - fdDir := filepath.Join("/proc", pid, "fd") - stat, err := os.Stat(fdDir) - if err != nil { - return false - } - - // If fd directory was modified recently, there's active FD usage - return time.Since(stat.ModTime()) < 5*time.Minute -} - // isInteractiveProcess detects if a process is interactive by checking: // 1. Is it in foreground process group? // 2. Is it waiting on TTY read OR has it ever read from TTY? -func isInteractiveProcess(pid string) bool { +func isInteractiveProcess(pid string, verbose bool) bool { if !isInForegroundProcessGroup(pid) { return false // Background processes are not interactive } @@ -1025,7 +1080,7 @@ func isInteractiveProcess(pid string) bool { } // Has it ever read from TTY? - if hasEverReadFromTTY(pid) { + if hasEverReadFromTTY(pid, verbose) { return true } @@ -1061,36 +1116,139 @@ func processHasTTY(pid string) bool { } // hasRecentActivity checks if a process has had recent I/O activity -func hasRecentActivity(activityWindow time.Duration, pid string) bool { +func hasRecentActivity(activityWindow time.Duration, pid string, verbose bool) bool { window := activityWindow if window <= 0 { window = DefaultActivityWindow } - // Check TTY access time for user input activity - return hasTTYActivity(pid, window) + return hasTTYActivity(pid, window, verbose) +} + +// checkDevptsAtimeSupport inspects /proc/mounts for the devpts filesystem (backing +// /dev/pts/*, i.e. workspace terminals) and warns once at startup if it's mounted with +// `noatime`. TTY atime is the primary signal for interactive activity; when disabled, +// CLI Watcher falls back to CPU-usage based detection (see hasTTYActivity / +// hasRecentCPUActivity), which is coarser — it can tell a process did *something*, not +// specifically that a user typed something. +func checkDevptsAtimeSupport() { + data, err := os.ReadFile("/proc/mounts") + if err != nil { + logrus.Debugf("CLI Watcher: Could not read /proc/mounts to check devpts atime support: %v", err) + return + } + + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "devpts" { + continue + } + + options := strings.Split(fields[3], ",") + if slices.Contains(options, "noatime") { + logrus.Warnf("CLI Watcher: devpts (%s) is mounted with 'noatime' — TTY access-time tracking is disabled, so interactive-process activity will be detected via a CPU-usage fallback instead of keystroke timing (coarser; may keep workspaces alive slightly longer than expected)", fields[1]) + } else { + logrus.Debugf("CLI Watcher: devpts (%s) mount options: %s (atime tracking available)", fields[1], fields[3]) + } + return + } + + logrus.Debugf("CLI Watcher: No devpts mount found in /proc/mounts, cannot verify TTY atime support") } -// hasTTYActivity checks if the TTY has been accessed recently -func hasTTYActivity(pid string, window time.Duration) bool { +// hasTTYActivity checks if the TTY has been accessed recently, using atime when it's +// reliable (has advanced past process start), falling back to CPU-usage tracking when +// it hasn't — e.g. under a `noatime` devpts mount, where atime for a shared pty never +// advances past whatever value it had before this process even started. +func hasTTYActivity(pid string, window time.Duration, verbose bool) bool { + startTime := getProcessStartTime(pid) _, atime, valid := getCachedTTYInfo(pid) - if !valid { + + if valid && !startTime.IsZero() && atime.After(startTime) { + threshold := time.Now().Add(-window) + return atime.After(threshold) + } + + activityLogf(verbose, "CLI Watcher: TTY atime for PID %s unavailable or unreliable, using CPU-activity fallback", pid) + return hasRecentCPUActivity(pid, window, verbose) +} + +// cpuActivityCache tracks per-process CPU ticks (utime+stime) across check cycles, used +// as an atime-independent activity signal when TTY atime is unavailable or unreliable +// (e.g. a `noatime` devpts mount, where atime for a shared pty never advances). +var ( + cpuActivityCache = make(map[string]*cpuActivitySample) + cpuActivityCacheMutex sync.Mutex +) + +type cpuActivitySample struct { + lastTicks int64 + lastActiveAt time.Time +} + +const cpuActivityCacheCleanupAt = 800 // Trigger cleanup when reaching this size + +// cleanupCPUActivityCache removes entries for PIDs that no longer exist. +// MUST be called with cpuActivityCacheMutex held. +func cleanupCPUActivityCache() { + for pid := range cpuActivityCache { + if _, err := os.Stat(filepath.Join("/proc", pid)); os.IsNotExist(err) { + delete(cpuActivityCache, pid) + } + } +} + +// getProcessCPUTicks returns the total CPU ticks (utime+stime) consumed by the process. +func getProcessCPUTicks(pid string) (int64, bool) { + stat, err := parseProcStat(pid) + if err != nil { + return 0, false + } + return stat.utimeTicks + stat.stimeTicks, true +} + +// hasRecentCPUActivity reports whether a process has consumed any CPU since it was last +// sampled, tracking a per-PID "last seen active" timestamp across check cycles. Used as +// a fallback for hasTTYActivity when TTY atime can't be trusted. +func hasRecentCPUActivity(pid string, window time.Duration, verbose bool) bool { + ticks, ok := getProcessCPUTicks(pid) + if !ok { return false } - threshold := time.Now().Add(-window) - return atime.After(threshold) + cpuActivityCacheMutex.Lock() + defer cpuActivityCacheMutex.Unlock() + + now := time.Now() + sample, exists := cpuActivityCache[pid] + if !exists { + if len(cpuActivityCache) >= cpuActivityCacheCleanupAt { + cleanupCPUActivityCache() + } + cpuActivityCache[pid] = &cpuActivitySample{lastTicks: ticks, lastActiveAt: now} + activityLogf(verbose, "CLI Watcher: PID %s has no CPU-activity baseline yet, assuming active", pid) + return true + } + + if ticks > sample.lastTicks { + sample.lastTicks = ticks + sample.lastActiveAt = now + } + + recentlyActive := now.Sub(sample.lastActiveAt) < window + activityLogf(verbose, "CLI Watcher: PID %s CPU-activity fallback: recent=%v (last active %v ago)", pid, recentlyActive, now.Sub(sample.lastActiveAt).Round(time.Second)) + return recentlyActive } // Finds the CLI Watcher configuration file in: -// 1. Use explicit override by using "CLI_WATCHER_CONFIG" env. variable, or if not set then +// 1. Use explicit override by using "CLI_ACTIVITY_TRACKER_CONFIG" env. variable, or if not set then // 2. Search for '.noidle' upward from current project directory up to "PROJECTS_ROOT" directory, or // 3. Fallback to $HOME/. file, or if doesn't exist/isn't accessble then // 4. Otherwise, give up. Repeating the search on next run (thus waiting for a config to appear) func getConfigPath() string { // 1. Use explicit override - if configEnv := os.Getenv("CLI_WATCHER_CONFIG"); configEnv != "" { + if configEnv := os.Getenv("CLI_ACTIVITY_TRACKER_CONFIG"); configEnv != "" { return configEnv } @@ -1178,7 +1336,7 @@ func findUpward(start, stop, filename string) string { func (w *cliWatcher) loadConfig(path string, current *cliWatcherConfig) (*cliWatcherConfig, error) { info, err := os.Stat(path) if os.IsNotExist(err) { - if current != nil { + if current != nil && current._fromFile { logrus.Infof("CLI Watcher: Config file at %s was removed, stopping config-based detection", path) } else if !w.warnedMissingConfig { if strings.TrimSpace(path) == "" { @@ -1188,7 +1346,17 @@ func (w *cliWatcher) loadConfig(path string, current *cliWatcherConfig) (*cliWat } w.warnedMissingConfig = true } - return nil, nil + + // Already on env vars + defaults, nothing changed — stay quiet + if current != nil && !current._fromFile { + return current, nil + } + + // No .noidle file — build config from env vars and defaults only + var defaultCfg cliWatcherConfig + defaultCfg = applyDefaults(defaultCfg, w.idleTimeout) + defaultCfg = w.applyEnvCeilings(defaultCfg, false) + return &defaultCfg, nil } else if err != nil { return current, fmt.Errorf("CLI Watcher: Failed to stat config file: %w", err) } @@ -1221,8 +1389,10 @@ func (w *cliWatcher) loadConfig(path string, current *cliWatcherConfig) (*cliWat } newCfg._lastModTime = info.ModTime() + newCfg._fromFile = true newCfg = applyDefaults(newCfg, w.idleTimeout) newCfg = ignoreExclusions(alwaysIgnoredCommands, newCfg) + newCfg = w.applyEnvCeilings(newCfg, true) // Log config changes logrus.Infof("CLI Watcher: Config reloaded from %s", path) @@ -1479,3 +1649,66 @@ func applyDefaults(c cliWatcherConfig, idleTimeout time.Duration) cliWatcherConf return c } + +func (w *cliWatcher) applyEnvCeilings(c cliWatcherConfig, noidle bool) cliWatcherConfig { + env := &w.envConfig + + // --- enabled: always admin-controlled --- + resolvedEnabled := DefaultCliWatcherEnabled + if env.enabled != nil { + resolvedEnabled = *env.enabled + } + + if noidle && c.Enabled != resolvedEnabled { + if env.enabled != nil { + logrus.Infof("CLI Watcher: 'enabled' = %t (from %s; .noidle 'enabled: %t' rejected — deprecated, admin-controlled)", resolvedEnabled, EnvCliWatcherEnabled, c.Enabled) + } else { + logrus.Infof("CLI Watcher: 'enabled' = %t (default; .noidle 'enabled: %t' rejected — deprecated, use %s env var)", resolvedEnabled, c.Enabled, EnvCliWatcherEnabled) + } + } else if noidle { + if env.enabled != nil { + logrus.Infof("CLI Watcher: 'enabled' = %t (from %s; .noidle 'enabled' is deprecated — admin-controlled)", resolvedEnabled, EnvCliWatcherEnabled) + } else { + logrus.Infof("CLI Watcher: 'enabled' = %t (default; .noidle 'enabled' is deprecated — use %s env var)", resolvedEnabled, EnvCliWatcherEnabled) + } + } else { + if env.enabled != nil { + logrus.Infof("CLI Watcher: 'enabled' = %t (from %s)", resolvedEnabled, EnvCliWatcherEnabled) + } else { + logrus.Infof("CLI Watcher: 'enabled' = %t (default)", resolvedEnabled) + } + } + c.Enabled = resolvedEnabled + + // --- verbose: admin-controlled activity-detection logging, no .noidle equivalent --- + c._verbose = env.verbose != nil && *env.verbose + + // --- timing params: env var is ceiling, .noidle can only tighten --- + applyDurationCeiling := func(fieldName, envName, noidleRaw string, envVal *time.Duration, parsed *time.Duration) { + noifileSet := noidle && noidleRaw != "" + if envVal != nil { + if noifileSet && *parsed > *envVal { + logrus.Infof("CLI Watcher: '%s' = %v (admin limit; .noidle value %v rejected — exceeds admin ceiling)", fieldName, *envVal, *parsed) + *parsed = *envVal + } else if noifileSet { + logrus.Infof("CLI Watcher: '%s' = %v (from .noidle; within admin limit %v)", fieldName, *parsed, *envVal) + } else { + logrus.Infof("CLI Watcher: '%s' = %v (from %s)", fieldName, *envVal, envName) + *parsed = *envVal + } + } else { + if noifileSet { + logrus.Infof("CLI Watcher: '%s' = %v (from .noidle)", fieldName, *parsed) + } else { + logrus.Infof("CLI Watcher: '%s' = %v (default)", fieldName, *parsed) + } + } + } + + applyDurationCeiling("checkPeriod", EnvCliWatcherCheckPeriod, c.CheckPeriod, env.checkPeriod, &c._checkPeriodParsed) + applyDurationCeiling("activityWindow", EnvCliWatcherActivityWindow, c.ActivityWindow, env.activityWindow, &c._activityWindowParsed) + applyDurationCeiling("gracePeriod", EnvCliWatcherGracePeriod, c.GracePeriod, env.gracePeriod, &c._gracePeriodParsed) + applyDurationCeiling("maxProcessAge", EnvCliWatcherMaxProcessAge, c.MaxProcessAge, env.maxProcessAge, &c._maxProcessAgeParsed) + + return c +} diff --git a/timeout/cli-watcher_test.go b/timeout/cli-watcher_test.go index bb1d8061..11ed8562 100644 --- a/timeout/cli-watcher_test.go +++ b/timeout/cli-watcher_test.go @@ -514,8 +514,8 @@ func TestApplyPolicy(t *testing.T) { note: "Empty mode should behave as non-interactive", }, // Note: Cannot fully test interactive modes without real /proc: - // - InteractiveModeAuto calls isInteractiveProcess(pid) which needs /proc - // - InteractiveModeTrue/Yes call hasRecentActivity(pid) which needs /proc/[pid]/fd/0 + // - InteractiveModeAuto calls isInteractiveProcess(pid, verbose) which needs /proc + // - InteractiveModeTrue/Yes call hasRecentActivity(activityWindow, pid, verbose) which needs /proc/[pid]/fd/0 // These require integration testing with real processes } @@ -523,7 +523,7 @@ func TestApplyPolicy(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Use a non-existent PID since we're only testing non-interactive modes // which don't call process inspection functions - result := applyPolicy("99999", "testcmd", tt.mode, 60*time.Second, "test") + result := applyPolicy("99999", "testcmd", tt.mode, 60*time.Second, "test", false) if result != tt.expectedResult { t.Errorf("applyPolicy with mode %q = %v, want %v (%s)", @@ -710,9 +710,15 @@ func TestProcParsing(t *testing.T) { if stat.startTicks <= 0 { t.Errorf("parseProcStat returned invalid startTicks: %d", stat.startTicks) } + if stat.utimeTicks < 0 { + t.Errorf("parseProcStat returned negative utimeTicks: %d", stat.utimeTicks) + } + if stat.stimeTicks < 0 { + t.Errorf("parseProcStat returned negative stimeTicks: %d", stat.stimeTicks) + } - t.Logf("Current process stats: ppid=%s, pgrp=%d, tpgid=%d, startTicks=%d", - stat.ppid, stat.pgrp, stat.tpgid, stat.startTicks) + t.Logf("Current process stats: ppid=%s, pgrp=%d, tpgid=%d, utimeTicks=%d, stimeTicks=%d, startTicks=%d", + stat.ppid, stat.pgrp, stat.tpgid, stat.utimeTicks, stat.stimeTicks, stat.startTicks) }) t.Run("parseProcStat with init process", func(t *testing.T) { @@ -842,6 +848,89 @@ func TestProcParsing(t *testing.T) { }) } +// Test getProcessCPUTicks, used by the CPU-usage activity fallback +func TestGetProcessCPUTicks(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping /proc parsing tests on non-Linux platform") + } + + t.Run("current process returns non-negative ticks", func(t *testing.T) { + myPID := fmt.Sprintf("%d", os.Getpid()) + ticks, ok := getProcessCPUTicks(myPID) + if !ok { + t.Fatalf("getProcessCPUTicks(%s) failed", myPID) + } + if ticks < 0 { + t.Errorf("getProcessCPUTicks returned negative ticks: %d", ticks) + } + }) + + t.Run("invalid PID returns false", func(t *testing.T) { + _, ok := getProcessCPUTicks("999999") + if ok { + t.Error("getProcessCPUTicks(999999) should fail for non-existent PID") + } + }) +} + +// Test hasRecentCPUActivity, the atime-independent fallback used by hasTTYActivity +// when TTY atime is unavailable or unreliable (e.g. noatime devpts mounts) +func TestHasRecentCPUActivity(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping CPU-activity fallback tests on non-Linux platform") + } + + myPID := fmt.Sprintf("%d", os.Getpid()) + + t.Run("first observation returns true (no baseline yet)", func(t *testing.T) { + cpuActivityCache = make(map[string]*cpuActivitySample) + + if !hasRecentCPUActivity(myPID, time.Minute, false) { + t.Error("hasRecentCPUActivity should return true on first observation (benefit of doubt)") + } + }) + + t.Run("recent baseline still counts as active", func(t *testing.T) { + cpuActivityCache = make(map[string]*cpuActivitySample) + hasRecentCPUActivity(myPID, time.Minute, false) // establish baseline + + if !hasRecentCPUActivity(myPID, time.Minute, false) { + t.Error("hasRecentCPUActivity should still be true immediately after establishing baseline") + } + }) + + t.Run("stale baseline with no new CPU ticks returns false", func(t *testing.T) { + cpuActivityCache = make(map[string]*cpuActivitySample) + cpuActivityCache[myPID] = &cpuActivitySample{ + lastTicks: 1 << 30, // implausibly high so real CPU usage won't exceed it + lastActiveAt: time.Now().Add(-time.Hour), + } + + if hasRecentCPUActivity(myPID, time.Minute, false) { + t.Error("hasRecentCPUActivity should return false when last activity predates the window and ticks haven't increased") + } + }) + + t.Run("invalid PID returns false", func(t *testing.T) { + cpuActivityCache = make(map[string]*cpuActivitySample) + + if hasRecentCPUActivity("999999", time.Minute, false) { + t.Error("hasRecentCPUActivity should return false for a non-existent PID") + } + }) +} + +// Smoke test for checkDevptsAtimeSupport: verify it doesn't panic against the real +// /proc/mounts. It only logs, so there's nothing else to assert without adding a +// logrus-capture harness. +func TestCheckDevptsAtimeSupport(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping devpts atime check on non-Linux platform") + } + + checkDevptsAtimeSupport() +} + // Test backward compatibility with deprecated fields func TestBackwardCompatibility(t *testing.T) { tests := []struct { @@ -883,3 +972,195 @@ func TestBackwardCompatibility(t *testing.T) { }) } } + +// Test loadEnvConfig parsing +func TestLoadEnvConfig(t *testing.T) { + t.Run("no env vars set", func(t *testing.T) { + for _, env := range []string{EnvCliWatcherEnabled, EnvCliWatcherCheckPeriod, EnvCliWatcherActivityWindow, EnvCliWatcherGracePeriod, EnvCliWatcherMaxProcessAge} { + os.Unsetenv(env) + } + cfg := loadEnvConfig() + if cfg.enabled != nil { + t.Errorf("enabled should be nil when env var not set, got %v", *cfg.enabled) + } + if cfg.checkPeriod != nil { + t.Errorf("checkPeriod should be nil when env var not set") + } + if cfg.activityWindow != nil { + t.Errorf("activityWindow should be nil when env var not set") + } + if cfg.gracePeriod != nil { + t.Errorf("gracePeriod should be nil when env var not set") + } + if cfg.maxProcessAge != nil { + t.Errorf("maxProcessAge should be nil when env var not set") + } + }) + + t.Run("enabled true", func(t *testing.T) { + t.Setenv(EnvCliWatcherEnabled, "true") + cfg := loadEnvConfig() + if cfg.enabled == nil || !*cfg.enabled { + t.Errorf("enabled should be true") + } + }) + + t.Run("enabled false", func(t *testing.T) { + t.Setenv(EnvCliWatcherEnabled, "false") + cfg := loadEnvConfig() + if cfg.enabled == nil || *cfg.enabled { + t.Errorf("enabled should be false") + } + }) + + t.Run("enabled invalid", func(t *testing.T) { + t.Setenv(EnvCliWatcherEnabled, "notabool") + cfg := loadEnvConfig() + if cfg.enabled != nil { + t.Errorf("enabled should be nil for invalid value, got %v", *cfg.enabled) + } + }) + + t.Run("duration env vars", func(t *testing.T) { + t.Setenv(EnvCliWatcherCheckPeriod, "45s") + t.Setenv(EnvCliWatcherActivityWindow, "20m") + t.Setenv(EnvCliWatcherGracePeriod, "3m") + t.Setenv(EnvCliWatcherMaxProcessAge, "4h") + cfg := loadEnvConfig() + if cfg.checkPeriod == nil || *cfg.checkPeriod != 45*time.Second { + t.Errorf("checkPeriod = %v, want 45s", cfg.checkPeriod) + } + if cfg.activityWindow == nil || *cfg.activityWindow != 20*time.Minute { + t.Errorf("activityWindow = %v, want 20m", cfg.activityWindow) + } + if cfg.gracePeriod == nil || *cfg.gracePeriod != 3*time.Minute { + t.Errorf("gracePeriod = %v, want 3m", cfg.gracePeriod) + } + if cfg.maxProcessAge == nil || *cfg.maxProcessAge != 4*time.Hour { + t.Errorf("maxProcessAge = %v, want 4h", cfg.maxProcessAge) + } + }) + + t.Run("duration as plain integer (seconds)", func(t *testing.T) { + t.Setenv(EnvCliWatcherCheckPeriod, "30") + cfg := loadEnvConfig() + if cfg.checkPeriod == nil || *cfg.checkPeriod != 30*time.Second { + t.Errorf("checkPeriod = %v, want 30s", cfg.checkPeriod) + } + }) +} + +// Test applyEnvCeilings +func TestApplyEnvCeilings(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + durPtr := func(d time.Duration) *time.Duration { return &d } + + t.Run("enabled from env var overrides noidle", func(t *testing.T) { + w := &cliWatcher{envConfig: cliWatcherEnvConfig{enabled: boolPtr(false)}} + cfg := cliWatcherConfig{Enabled: true} + result := w.applyEnvCeilings(cfg, true) + if result.Enabled { + t.Error("enabled should be false (admin override)") + } + }) + + t.Run("enabled uses default when env var not set", func(t *testing.T) { + w := &cliWatcher{envConfig: cliWatcherEnvConfig{}} + cfg := cliWatcherConfig{Enabled: true} + result := w.applyEnvCeilings(cfg, true) + if result.Enabled != DefaultCliWatcherEnabled { + t.Errorf("enabled should be %t (default), got %t", DefaultCliWatcherEnabled, result.Enabled) + } + }) + + t.Run("enabled without noidle uses env var", func(t *testing.T) { + w := &cliWatcher{envConfig: cliWatcherEnvConfig{enabled: boolPtr(true)}} + cfg := cliWatcherConfig{} + result := w.applyEnvCeilings(cfg, false) + if !result.Enabled { + t.Error("enabled should be true (from env var)") + } + }) + + t.Run("timing param clamped to admin ceiling", func(t *testing.T) { + adminLimit := 15 * time.Minute + w := &cliWatcher{envConfig: cliWatcherEnvConfig{activityWindow: durPtr(adminLimit)}} + cfg := cliWatcherConfig{ + ActivityWindow: "30m", + _activityWindowParsed: 30 * time.Minute, + } + result := w.applyEnvCeilings(cfg, true) + if result._activityWindowParsed != adminLimit { + t.Errorf("activityWindow = %v, want %v (admin ceiling)", result._activityWindowParsed, adminLimit) + } + }) + + t.Run("timing param accepted when stricter than ceiling", func(t *testing.T) { + adminLimit := 15 * time.Minute + noifileVal := 10 * time.Minute + w := &cliWatcher{envConfig: cliWatcherEnvConfig{activityWindow: durPtr(adminLimit)}} + cfg := cliWatcherConfig{ + ActivityWindow: "10m", + _activityWindowParsed: noifileVal, + } + result := w.applyEnvCeilings(cfg, true) + if result._activityWindowParsed != noifileVal { + t.Errorf("activityWindow = %v, want %v (noidle stricter)", result._activityWindowParsed, noifileVal) + } + }) + + t.Run("timing param uses env var when no noidle", func(t *testing.T) { + envVal := 20 * time.Minute + w := &cliWatcher{envConfig: cliWatcherEnvConfig{activityWindow: durPtr(envVal)}} + cfg := cliWatcherConfig{ + _activityWindowParsed: DefaultActivityWindow, + } + result := w.applyEnvCeilings(cfg, false) + if result._activityWindowParsed != envVal { + t.Errorf("activityWindow = %v, want %v (from env)", result._activityWindowParsed, envVal) + } + }) + + t.Run("timing param uses default when no env and no noidle", func(t *testing.T) { + w := &cliWatcher{envConfig: cliWatcherEnvConfig{}} + cfg := cliWatcherConfig{ + _activityWindowParsed: DefaultActivityWindow, + } + result := w.applyEnvCeilings(cfg, false) + if result._activityWindowParsed != DefaultActivityWindow { + t.Errorf("activityWindow = %v, want %v (default)", result._activityWindowParsed, DefaultActivityWindow) + } + }) + + t.Run("all timing params clamped", func(t *testing.T) { + w := &cliWatcher{envConfig: cliWatcherEnvConfig{ + checkPeriod: durPtr(30 * time.Second), + activityWindow: durPtr(10 * time.Minute), + gracePeriod: durPtr(2 * time.Minute), + maxProcessAge: durPtr(3 * time.Hour), + }} + cfg := cliWatcherConfig{ + CheckPeriod: "60s", + ActivityWindow: "25m", + GracePeriod: "5m", + MaxProcessAge: "6h", + _checkPeriodParsed: 60 * time.Second, + _activityWindowParsed: 25 * time.Minute, + _gracePeriodParsed: 5 * time.Minute, + _maxProcessAgeParsed: 6 * time.Hour, + } + result := w.applyEnvCeilings(cfg, true) + if result._checkPeriodParsed != 30*time.Second { + t.Errorf("checkPeriod = %v, want 30s", result._checkPeriodParsed) + } + if result._activityWindowParsed != 10*time.Minute { + t.Errorf("activityWindow = %v, want 10m", result._activityWindowParsed) + } + if result._gracePeriodParsed != 2*time.Minute { + t.Errorf("gracePeriod = %v, want 2m", result._gracePeriodParsed) + } + if result._maxProcessAgeParsed != 3*time.Hour { + t.Errorf("maxProcessAge = %v, want 3h", result._maxProcessAgeParsed) + } + }) +}