diff --git a/CHANGELOG.md b/CHANGELOG.md index 40dfce3b..68003090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Breaking (3.0.0) + +- **`ccxray setup` is record-only by default; export is opt-in (#691).** Plain `ccxray setup` installs the Claude shell wrapper and local recording settings (`LOG_RETENTION_DAYS`, `CCXRAY_IMPORT_DISABLE=1`) only. It writes no export settings, enrollment, or cursor, needs no `--bucket`/`--domains`, and never reads gcloud Application Default Credentials. Enable summary export with `ccxray setup --export --bucket --domains `, which behaves exactly as setup did in 2.x; `--bucket`, `--domains`, `--prefix`, and `--cwd-allowlist` are refused without `--export`. Upgrades keep exporting: an install whose shell block already exports stays exporting when `ccxray setup` is re-run without `--export`. Setup leaves that block byte-for-byte unchanged, does not read gcloud credentials or rewrite `export-enrollment.json`, and prints one line per such shell naming `ccxray setup --export` (refresh and keep exporting) and `ccxray setup --remove` (stop). With several shell targets each is handled on its own, so a record-only shell is never rewritten as an export block and a mixed install no longer fails with "--export needs --bucket"; `--export` takes bucket and domains from the existing export block. `ccxray setup --check` skips the ADC check when export is not configured. `ccxray setup --help` documents both modes. +- **Context HUD is off by default; new `ccxray hud on|off|status` (#692).** The HUD footer can truncate subagent returns, so a missing `statusLine` setting now means off. A `settings.json` with an explicit `"statusLine": true` keeps the HUD on after upgrading. Note that the dashboard used to persist the then-default `true` whenever it saved settings (for example when starring), so such homes keep the HUD on; run `ccxray hud off` to turn it off. `ccxray hud on|off` writes the same `/settings.json` the dashboard toggle uses (atomically through a symlinked file, owner-only, keeping the other settings, and refusing a file that is not a JSON object; `hud status` on such a file reports it and exits non-zero); a running hub or server applies a CLI change when it restarts, while the dashboard toggle still applies immediately. Dashboard writes patch only the keys they change into the file as it is on disk, so they no longer revert a `ccxray hud on|off` made while the server runs. The `Context HUD: enabled|disabled` line is printed only by proxy startup, standalone agent launches, and `ccxray hud status`, not by other subcommands. + ### Fixed - **Codex ChatGPT-login works through ccxray again (#671).** Codex 0.159.2 and 0.163.0 failed to start with `-32603 workspace routing discovery failed`, because the launcher pointed `chatgpt_base_url` at the plaintext loopback proxy. In ChatGPT-OAuth mode `ccxray codex` and the Herdr Codex launch now set only `openai_base_url=/v1`: account and workspace-discovery calls go directly to chatgpt.com, and model traffic is still recorded. API-key mode is unchanged. `ccxray codex` warns (read-only) when `$CODEX_HOME/config.toml` still points `chatgpt_base_url` at a loopback proxy, which keeps failing. Residency/`us` workspaces are not re-routed for the local proxy. See [notes](docs/codex-workspace-routing.md). diff --git a/CLAUDE.md b/CLAUDE.md index ba11c005..f60a7235 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,9 @@ ccxray grok # Proxy + Grok CLI (xAI) ccxray --port 8080 claude # Custom port (opts out of hub, independent server) ccxray status # Show process, export, home, and hub status ccxray --port 8080 status # Checks hub lock/socket first; probes this port only if no lock exists -ccxray setup --bucket --domains example.com # Export settings + Claude wrapper; import stays disabled +ccxray setup # Claude wrapper + local recording only; import stays disabled +ccxray setup --export --bucket --domains example.com # Also enroll in summary export (opt-in) +ccxray hud on|off|status # Context HUD toggle (off by default; same settings.json as the dashboard) ccxray # Proxy + dashboard only npm run dev # Dev mode (auto-restart on server/public changes) npm test # Run tests diff --git a/README.ja.md b/README.ja.md index 561b91b2..ad6a731e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -33,19 +33,25 @@ npx ccxray grok ccxray が proxy を読み込めない、または hub に接続できない場合、agent を直接起動し、その session は記録されません。hub 接続の予算時間はデフォルトで 3000 ms です。未解決の接続は時間切れで fallback し、それより前に失敗すれば早く fallback します。`CCXRAY_ATTACH_TIMEOUT_MS` で予算時間を変更できます。ccxray は `/fail-open.log`(デフォルトは `~/.ccxray/fail-open.log`)への記録を試みますが、書き込みは best effort で失敗することもあります。`CCXRAY_FAIL_CLOSED=1` を設定すると、fallback せずに起動を停止します。Claude の `--bg` / `--background` launch は仕様上 proxy を通りません。 -Claude の shell wrapper と任意のサマリー出力を設定して継続利用する場合: - -`setup` を実行する前に Claude Code をインストールし、ログイン済みアカウントのメールアドレスを ccxray が取得できることを確認してください。`--domains` のリストにはそのアカウントのドメインを含めます。setup には読み取り可能な gcloud Application Default Credentials も必要です。先に `gcloud auth application-default login` を実行してください。exporter 自体は service-account key file に対応していますが、setup は `CCXRAY_EXPORT_GCS_KEY_FILE` を拒否します。 +Claude の shell wrapper を設定して継続利用する場合(ローカル記録のみ): ```bash npm install -g ccxray -gcloud auth application-default login -ccxray setup --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +ccxray setup # 新しいターミナルを開く(または shell profile を再読み込み)し、設定を確認: ccxray setup --check ``` -`setup` は shell profile に Claude wrapper と出力設定を追加し、ログ保持期間を設定して `CCXRAY_IMPORT_DISABLE=1` を書き込みます。Codex wrapper は追加しません。`ccxray setup --dry-run`(同じ setup オプションを指定)で変更をプレビューできます。`ccxray setup --remove` は ccxray が書き込んだ shell block を削除して tombstone を書き、今後のアップロードを停止します。ローカルログやアップロード済みサマリーは削除しません。詳細は[エクスポート設定ガイド](docs/export-onboarding.md)を参照してください。 +`ccxray setup` だけでは出力設定を書き込まず、bucket・domains・gcloud 認証情報も不要です。GCS へのサマリー出力は `--export` で明示的に有効にします。使用前に Claude Code をインストールし、ログイン済みアカウントのメールアドレスを ccxray が取得できることを確認してください。`--domains` のリストにはそのアカウントのドメインを含めます。出力の設定には読み取り可能な gcloud Application Default Credentials も必要です。先に `gcloud auth application-default login` を実行してください。exporter 自体は service-account key file に対応していますが、setup は `CCXRAY_EXPORT_GCS_KEY_FILE` を拒否します。 + +```bash +gcloud auth application-default login +ccxray setup --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +``` + +すでに出力が有効なインストール(2.x で設定)は、`--export` なしで `ccxray setup` を再実行しても出力を続けます。setup はその旨を 1 行表示します。 + +`setup` は shell profile に Claude wrapper(`--export` 指定時は出力設定も)を追加し、ログ保持期間を設定して `CCXRAY_IMPORT_DISABLE=1` を書き込みます。Codex wrapper は追加しません。`ccxray setup --dry-run`(同じ setup オプションを指定)で変更をプレビューできます。`ccxray setup --remove` は ccxray が書き込んだ shell block を削除して tombstone を書き、今後のアップロードを停止します。ローカルログやアップロード済みサマリーは削除しません。詳細は[エクスポート設定ガイド](docs/export-onboarding.md)を参照してください。 launcher の引数は provider registry で管理されています。現在 `claude`・`codex`・`grok` に対応。未知の provider コマンドは即座に失敗し、未設定のプロキシが静かに起動することはありません。 @@ -218,9 +224,9 @@ ccxray は Anthropic の `cache_creation` 使用量フィールドを読み取 ### Context HUD -オプションのコンテキスト統計フッターを Claude Code 内の Claude の応答末尾に追加できます: `📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`。デフォルトで有効。ダッシュボードのトップバーから切り替え可能です。 +オプションのコンテキスト統計フッターを Claude Code 内の Claude の応答末尾に追加できます: `📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`。デフォルトは無効です(3.0.0 以降)。`ccxray hud on` またはダッシュボードのトップバーで有効にし、`ccxray hud status` で確認、`ccxray hud off` で無効にできます。 -**なぜ切り替えが必要なのか?** 親エージェントが Agent / Task ツール経由で sub-agent を呼び出す場合、追加されるブロックが sub-agent の応答を親エージェントに返る前に切り詰めてしまい、マルチエージェントワークフローでデータが静かに失われる可能性があります。sub-agent を多用するセッションでは HUD を無効にしてください。状態は `~/.ccxray/settings.json` に保存されます。 +**なぜ切り替えが必要なのか?** 親エージェントが Agent / Task ツール経由で sub-agent を呼び出す場合、追加されるブロックが sub-agent の応答を親エージェントに返る前に切り詰めてしまい、マルチエージェントワークフローでデータが静かに失われる可能性があります。sub-agent を多用するセッションや自動化されたセッションでは HUD を無効のままにしてください。状態は `~/.ccxray/settings.json` に保存されます(CLI とダッシュボードの切り替えで共有)。そこで明示的に `"statusLine": true` を設定している場合は、アップグレード後も有効のままです。実行中の hub や server は再起動後に CLI の変更を反映し、ダッシュボードの切り替えは即座に反映されます。 ### ずっと残すための Star diff --git a/README.md b/README.md index 2f9b164c..68f0c7f1 100644 --- a/README.md +++ b/README.md @@ -33,21 +33,27 @@ The proxy starts, the selected CLI launches through it, and the dashboard opens If ccxray cannot load the proxy or attach to a hub, it starts the agent directly and that session is not recorded. The hub attach budget is 3000 ms by default; an unresolved attach falls back when the budget expires, and an earlier failure can fall back sooner. Set `CCXRAY_ATTACH_TIMEOUT_MS` to change the budget. ccxray attempts to append a fallback record to `/fail-open.log` (default `~/.ccxray/fail-open.log`), but writing that file is best effort. Set `CCXRAY_FAIL_CLOSED=1` to stop instead of falling back. Claude `--bg` / `--background` launches bypass the proxy by design. -For a persistent install with Claude's shell wrapper and optional summary export: - -Before running `setup`, install Claude Code and make sure its logged-in account email is available to ccxray. The `--domains` list must include that account's domain. Setup also requires readable gcloud Application Default Credentials; run `gcloud auth application-default login` first. Setup rejects `CCXRAY_EXPORT_GCS_KEY_FILE`, even though the exporter itself supports a service-account key file. +For a persistent install with Claude's shell wrapper (local recording only): ```bash npm install -g ccxray -gcloud auth application-default login -ccxray setup --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +ccxray setup # Open a new terminal (or reload your shell profile), then verify: ccxray setup --check ``` +Plain `ccxray setup` writes no export settings and needs no bucket, domains, or gcloud credentials. Summary export to GCS is opt-in with `--export`. Before using it, install Claude Code and make sure its logged-in account email is available to ccxray. The `--domains` list must include that account's domain. Export setup also requires readable gcloud Application Default Credentials; run `gcloud auth application-default login` first. Setup rejects `CCXRAY_EXPORT_GCS_KEY_FILE`, even though the exporter itself supports a service-account key file. + +```bash +gcloud auth application-default login +ccxray setup --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +``` + +An install that already exports (set up by 2.x) keeps exporting when `ccxray setup` is re-run without `--export`: setup leaves its export block unchanged, does not read credentials or rewrite enrollment, and prints one line naming `ccxray setup --export` (refresh) and `ccxray setup --remove` (stop). + Transcript import is off by default on fresh installs and upgrades, in both hub and standalone mode. Set `CCXRAY_IMPORT_DISABLE=0` before starting ccxray or Herdr to opt in to automatic transcript import (restart an existing hub to apply it). For a one-time import, run `ccxray import --once`. `CCXRAY_IMPORT_DISABLE=1` blocks even explicit imports; unset it for a one-time import. Existing indexed history remains available. -`setup` adds a Claude wrapper and export settings to your shell profile, sets log retention, and writes `CCXRAY_IMPORT_DISABLE=1`. It does not install a Codex wrapper. Preview changes with `ccxray setup --dry-run` (include the same setup options). `ccxray setup --remove` removes ccxray's shell block and writes a tombstone to stop future uploads; it does not delete local logs or uploaded summaries. See the [export onboarding guide](docs/export-onboarding.md) for the data and removal details. +`setup` adds a Claude wrapper to your shell profile (plus export settings with `--export`), sets log retention, and writes `CCXRAY_IMPORT_DISABLE=1`. It does not install a Codex wrapper. Preview changes with `ccxray setup --dry-run` (include the same setup options). `ccxray setup --remove` removes ccxray's shell block and writes a tombstone to stop future uploads; it does not delete local logs or uploaded summaries. See the [export onboarding guide](docs/export-onboarding.md) for the data and removal details. ### Herdr plugin @@ -220,9 +226,9 @@ Pause requests before they reach Anthropic. Toggle intercept on a session and th ### Context HUD -Optional context-stats footer appended to Claude's responses inside Claude Code itself: `📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`. Enabled by default; toggle from the dashboard topbar. +Optional context-stats footer appended to Claude's responses inside Claude Code itself: `📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`. Off by default (since 3.0.0); turn it on with `ccxray hud on` or from the dashboard topbar, check it with `ccxray hud status`, and turn it off with `ccxray hud off`. -**Why a toggle?** When the parent agent calls sub-agents (Agent / Task tool), the appended block can truncate the sub-agent's response before it's returned to the parent — causing silent data loss in multi-agent workflows. Turn the HUD off when running sub-agent-heavy sessions. State persists in `~/.ccxray/settings.json`. +**Why a toggle?** When the parent agent calls sub-agents (Agent / Task tool), the appended block can truncate the sub-agent's response before it's returned to the parent — causing silent data loss in multi-agent workflows. Keep the HUD off for sub-agent-heavy or automated sessions. State persists in `~/.ccxray/settings.json` (shared by the CLI and the dashboard toggle); an explicit `"statusLine": true` there stays enabled after upgrading. A running hub or server picks up a CLI change when it restarts; the dashboard toggle applies immediately. ### Star to Keep Forever @@ -321,7 +327,7 @@ Every environment variable, with its default and source file, is listed in [docs Raw request and response logs are stored locally in `LOGS_DIR` (by default `~/.ccxray/logs/`) as `{timestamp}_req.json` and `{timestamp}_res.json`. Upgrading from v1.0? Logs previously in `./logs/` are automatically migrated on first run. -If you configure export with `ccxray setup`, ccxray can upload daily and per-session summaries; it does not upload the raw request/response logs. Summaries can include user identity, session IDs, repository basenames, model names, and tool or skill names. Source-derived names and metadata are not comprehensively scrubbed, so review them before enabling export. `CCXRAY_EXPORT_CWD_ALLOWLIST` masks an unlisted repository name as `[other]`; it does not exclude that session. Export requires a non-empty `CCXRAY_EXPORT_DOMAINS`; without it, uploads are blocked and the cursor is preserved. Only turns carrying a matching Claude launch-account snapshot are exported. Codex and Grok launches do not carry that snapshot and are excluded by this filter; this is the intended behavior. See the [export onboarding guide](docs/export-onboarding.md) for fields, filtering, and removal. +If you configure export with `ccxray setup --export`, ccxray can upload daily and per-session summaries; it does not upload the raw request/response logs. Summaries can include user identity, session IDs, repository basenames, model names, and tool or skill names. Source-derived names and metadata are not comprehensively scrubbed, so review them before enabling export. `CCXRAY_EXPORT_CWD_ALLOWLIST` masks an unlisted repository name as `[other]`; it does not exclude that session. Export requires a non-empty `CCXRAY_EXPORT_DOMAINS`; without it, uploads are blocked and the cursor is preserved. Only turns carrying a matching Claude launch-account snapshot are exported. Codex and Grok launches do not carry that snapshot and are excluded by this filter; this is the intended behavior. See the [export onboarding guide](docs/export-onboarding.md) for fields, filtering, and removal. ## Docker diff --git a/README.zh-TW.md b/README.zh-TW.md index 7cf79733..6bd35160 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -33,19 +33,25 @@ npx ccxray grok 如果 ccxray 無法載入 proxy 或連接 hub,會直接啟動 agent,該 session 不會被記錄。Hub 連線預設有 3000 ms 時限;未完成的連線會在時限到達時 fallback,若更早失敗也會提前 fallback。可用 `CCXRAY_ATTACH_TIMEOUT_MS` 調整時限。ccxray 會嘗試將 fallback 寫入 `/fail-open.log`(預設 `~/.ccxray/fail-open.log`),但寫檔屬於 best effort,可能失敗。設定 `CCXRAY_FAIL_CLOSED=1` 可改為停止啟動。Claude 的 `--bg` / `--background` launch 會依設計略過 proxy。 -若要持續安裝並設定 Claude shell wrapper 與選用的摘要匯出: - -執行 `setup` 前,請先安裝 Claude Code,並確認 ccxray 能讀取已登入帳號的 email;`--domains` 清單必須包含該帳號的網域。Setup 也需要可讀取的 gcloud Application Default Credentials,請先執行 `gcloud auth application-default login`。即使 exporter 本身支援 service-account key file,setup 仍會拒絕 `CCXRAY_EXPORT_GCS_KEY_FILE`。 +若要持續安裝並設定 Claude shell wrapper(僅本機記錄): ```bash npm install -g ccxray -gcloud auth application-default login -ccxray setup --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +ccxray setup # 開啟新終端機(或重新載入 shell profile),再確認設定: ccxray setup --check ``` -`setup` 會在 shell profile 加入 Claude wrapper 與匯出設定、設定日誌保留期限,並寫入 `CCXRAY_IMPORT_DISABLE=1`。它不會安裝 Codex wrapper。可用 `ccxray setup --dry-run` 預覽變更(帶上相同的 setup 選項)。`ccxray setup --remove` 會移除 ccxray 寫入的 shell 區塊,並寫入 tombstone 停止後續上傳;不會刪除本機日誌或已上傳的摘要。資料與移除方式詳見[匯出設定指南](docs/export-onboarding.md)。 +單純執行 `ccxray setup` 不會寫入任何匯出設定,也不需要 bucket、domains 或 gcloud 憑證。GCS 摘要匯出需以 `--export` 明確啟用。使用前請先安裝 Claude Code,並確認 ccxray 能讀取已登入帳號的 email;`--domains` 清單必須包含該帳號的網域。匯出設定也需要可讀取的 gcloud Application Default Credentials,請先執行 `gcloud auth application-default login`。即使 exporter 本身支援 service-account key file,setup 仍會拒絕 `CCXRAY_EXPORT_GCS_KEY_FILE`。 + +```bash +gcloud auth application-default login +ccxray setup --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +``` + +已啟用匯出的安裝(由 2.x 設定)在不帶 `--export` 重新執行 `ccxray setup` 時會繼續匯出,setup 會印出一行說明。 + +`setup` 會在 shell profile 加入 Claude wrapper(使用 `--export` 時另加匯出設定)、設定日誌保留期限,並寫入 `CCXRAY_IMPORT_DISABLE=1`。它不會安裝 Codex wrapper。可用 `ccxray setup --dry-run` 預覽變更(帶上相同的 setup 選項)。`ccxray setup --remove` 會移除 ccxray 寫入的 shell 區塊,並寫入 tombstone 停止後續上傳;不會刪除本機日誌或已上傳的摘要。資料與移除方式詳見[匯出設定指南](docs/export-onboarding.md)。 launcher 參數由 provider registry 管理。目前支援 `claude`、`codex` 與 `grok`;未知的 provider command 會直接失敗,避免靜默啟動未設定的 proxy。 @@ -218,9 +224,9 @@ ccxray 透過讀取 Anthropic 的 `cache_creation` 用量欄位,自動偵測 ### Context HUD -可選的上下文統計區塊,會被附加到 Claude Code 中 Claude 的回覆尾端:`📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`。預設啟用;可從儀表板頂部列切換。 +可選的上下文統計區塊,會被附加到 Claude Code 中 Claude 的回覆尾端:`📊 Context: 28% (290k/1M) | 1k in + 800 out | Cache 99% hit | $0.15`。預設關閉(自 3.0.0 起);可用 `ccxray hud on` 或儀表板頂部列開啟,用 `ccxray hud status` 查看,用 `ccxray hud off` 關閉。 -**為什麼需要這個開關?** 當主 agent 透過 Agent / Task tool 呼叫 sub-agent 時,附加的區塊可能會把 sub-agent 的回傳內容截斷在父 agent 看到的範圍之外,造成多 agent 工作流程靜默地遺失資料。跑 sub-agent 較重的 session 時請關掉 HUD。狀態保存在 `~/.ccxray/settings.json`。 +**為什麼需要這個開關?** 當主 agent 透過 Agent / Task tool 呼叫 sub-agent 時,附加的區塊可能會把 sub-agent 的回傳內容截斷在父 agent 看到的範圍之外,造成多 agent 工作流程靜默地遺失資料。跑 sub-agent 較重或自動化的 session 時請保持 HUD 關閉。狀態保存在 `~/.ccxray/settings.json`(CLI 與儀表板開關共用);其中明確設為 `"statusLine": true` 的設定在升級後仍維持啟用。執行中的 hub 或 server 會在重新啟動後套用 CLI 的變更;儀表板開關則立即生效。 ### 加星永久保留 diff --git a/docs/export-onboarding.md b/docs/export-onboarding.md index 52c2aa3c..6483a32c 100644 --- a/docs/export-onboarding.md +++ b/docs/export-onboarding.md @@ -2,7 +2,19 @@ ## Install and configure -Install ccxray. Before setup, make sure Claude Code is installed and ccxray can +Export is opt-in (3.0.0, #691). Plain `ccxray setup` installs only the Claude +shell wrapper and local recording settings: it writes no export settings, no +enrollment, and no cursor, and never reads gcloud credentials. Pass `--export` +with `--bucket` and `--domains` to enable summary export; the export flags are +refused without `--export`. An install whose owned block already exports keeps +exporting when `ccxray setup` is re-run without `--export`: setup leaves that +block byte-for-byte unchanged, does not read credentials or rewrite the +enrollment, and prints a one-line notice naming `ccxray setup --export` (refresh +and keep exporting) and `ccxray setup --remove` (stop). With several shell +targets, each is handled on its own: record-only shells are rewritten, export +shells are left as they are. + +Install ccxray. Before export setup, make sure Claude Code is installed and ccxray can read its logged-in account email; the `--domains` list must include that account's domain. Setup also requires a readable gcloud Application Default Credential of type `authorized_user`. Setup rejects @@ -12,7 +24,7 @@ account key file. For setup, use gcloud Application Default Credentials: ```bash npm install -g ccxray gcloud auth application-default login -ccxray setup --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 +ccxray setup --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" --retention-days 30 ``` Replace the placeholders with your GCS bucket, the account domain to allow, and @@ -43,8 +55,8 @@ On Windows, setup discovers both installed PowerShell editions by default. To select one explicitly, use either command: ```powershell -ccxray setup --shell pwsh --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" -ccxray setup --shell powershell --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" +ccxray setup --shell pwsh --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" +ccxray setup --shell powershell --export --bucket "" --domains example.com --cwd-allowlist "project-a,project-b" ``` Setup checks the selected edition's effective `Get-ExecutionPolicy`. It refuses @@ -89,7 +101,7 @@ intended behavior. When `CCXRAY_EXPORT_DOMAINS` is unset, empty, or contains only whitespace and commas, export is blocked: no uploads occur and the cursor stays unchanged. Startup prints one warning; `ccxray status` reports `refused (domains-required)`. -Set allowed domains with `ccxray setup --domains example.com`, reload your shell, +Set allowed domains with `ccxray setup --export --domains example.com`, reload your shell, and restart the exporter. Existing enrolled homes retain the usual bounded prune hold for unsent data. Imported transcripts have no account snapshot, so the configured-domain filter excludes them. `CCXRAY_USER_EMAIL`, when set, supplies diff --git a/server/forward.js b/server/forward.js index 6d32a9e8..2d367ce5 100644 --- a/server/forward.js +++ b/server/forward.js @@ -71,7 +71,7 @@ function computeThinkingStripped(isSubagent, reqSessionId, currMsgCount, parsedB } // ── Status line injection flag ──────────────────────────────────────── -let statusLineEnabled = true; +let statusLineEnabled = false; // #692: off until settings.json says otherwise function setStatusLineEnabled(val) { statusLineEnabled = !!val; } function getStatusLineEnabled() { return statusLineEnabled; } diff --git a/server/index.js b/server/index.js index 59d5ab8c..e0ad35bc 100755 --- a/server/index.js +++ b/server/index.js @@ -25,8 +25,9 @@ Commands: open Open dashboard in browser import Import transcripts (--once or --target-transcript) rebuild-index Rebuild index.ndjson from log files - setup Configure export enrollment and Claude shell wrapper + setup Install the Claude shell wrapper (export opt-in: --export) setup-statusline Configure Claude Code status line + hud on|off|status Turn the Context HUD on or off, or show it (off by default) secret Manage shared secrets (upstream) usage Show usage summary and costs agentflow report Usage per Ask of an agentflow notebook @@ -169,7 +170,7 @@ const { warmUp: warmUpCosts } = require('./cost-budget'); const exportSync = require('./export-sync'); const { startExportSync, stopExportSync, flushExport, awaitPendingFlush } = exportSync; const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled, setSessionAnchorRecorder } = require('./forward'); -const { readSettings } = require('./settings'); +const { readSettings, hudStatusText } = require('./settings'); const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast'); const { dispatch, mintAutoOpenUrl, formatAutoOpenUrl } = require('./auth'); const { findSharedPrefix } = require('./delta-helpers'); @@ -218,6 +219,7 @@ const unknownCommand = cliCommand && cliCommand !== 'import' && cliCommand !== 'setup' && cliCommand !== 'setup-statusline' + && cliCommand !== 'hud' && cliCommand !== 'usage' && !cliCommand.startsWith('-') && !providers.isAgentProvider(cliCommand); @@ -238,6 +240,53 @@ if (process.argv[2] === 'secret') { process.exit(1); } +// ── "hud on|off|status" — early exit, no server/hub boot (#692) ── +// Persists to the same settings.json the dashboard toggle writes. +if (process.argv[2] === 'hud') { + const HUD_USAGE = `Usage: ccxray hud on|off|status + + on Append the context-stats footer to Claude replies + off Do not append it (the default) + status Show the current setting + --help, -h Show this help + +The setting is stored in ${path.join(resolveCcxrayHome(), 'settings.json')}, shared with the +dashboard toggle. The HUD can truncate subagent returns; keep it off for +subagent-heavy or automated sessions. +`; + const sub = process.argv[3]; + const hudSettings = require('./settings'); + if (process.argv.length > 4 || (sub && !['on', 'off', 'status', '--help', '-h'].includes(sub))) { + process.stderr.write(`\x1b[31mError: unknown hud argument "${process.argv.slice(3).join(' ')}".\x1b[0m\n${HUD_USAGE}`); + process.exitCode = 1; + return; + } + if (!sub || sub === '--help' || sub === '-h') { + process.stdout.write(HUD_USAGE); + return; + } + if (sub === 'status') { + let enabled; + try { enabled = hudSettings.readStatusLineSync(); } + catch (err) { + console.error(`\x1b[31mError: ${err.message}\x1b[0m`); + process.exitCode = 1; + return; + } + process.stdout.write(hudSettings.hudStatusText(enabled) + '\n'); + return; + } + try { hudSettings.setStatusLineSync(sub === 'on'); } + catch (err) { + console.error(`\x1b[31mError: ${err.message}\x1b[0m`); + process.exitCode = 1; + return; + } + process.stdout.write(hudSettings.hudStatusText(sub === 'on') + '\n' + + 'A running ccxray hub or server keeps its current setting until it restarts; the dashboard toggle changes it live.\n'); + return; +} + // ── "import --once" — early exit, no server/hub boot ── // A throttled, lock-guarded transcript scan for a dashboard that has noticed the // index has fallen behind. Appends index lines only; see server/import-once.js @@ -369,7 +418,7 @@ const SECURITY_HEADERS = { }; // Load persisted settings and apply immediately -const settings = readSettings({ quiet: ['setup', 'setup-statusline', 'status', 'open'].includes(cliCommand) }); +const settings = readSettings(); setStatusLineEnabled(settings.statusLine); // index.html with config injection — built fresh per request so dynamic values (statusLine) stay current @@ -1611,6 +1660,7 @@ async function startServer({ onListen } = {}) { // Hub runs silently (logs go to hub.log) } else if (agentMode) { _origLog(`\x1b[90m${DISPLAY_NAME} → http://localhost:${actualPort}\x1b[0m`); + _origLog(`\x1b[90m ${hudStatusText(getStatusLineEnabled())}\x1b[0m`); } else { console.log(); console.log(`\x1b[35m🔌 ${DISPLAY_NAME} proxy listening on http://localhost:${actualPort}\x1b[0m`); @@ -1628,6 +1678,7 @@ async function startServer({ onListen } = {}) { console.log(` xAI Upstream → ${xaiUrl}${xaiNote}`); } console.log(` Logs → ${config.storage.location || config.LOGS_DIR}`); + console.log(` ${hudStatusText(getStatusLineEnabled())}`); console.log(); console.log(` Usage: ANTHROPIC_BASE_URL=http://localhost:${actualPort} claude`); console.log(` ccxray codex | ccxray grok\x1b[0m`); diff --git a/server/settings.js b/server/settings.js index 3a907891..31253b03 100644 --- a/server/settings.js +++ b/server/settings.js @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { resolveCcxrayHome } = require('./paths'); @@ -8,7 +9,9 @@ const SETTINGS_DIR = resolveCcxrayHome(); const SETTINGS_PATH = path.join(SETTINGS_DIR, 'settings.json'); const DEFAULTS = { - statusLine: true, + // #692 (3.0.0): the Context HUD is off unless settings.json says true — it can + // truncate subagent returns. An explicit `statusLine: true` is kept as is. + statusLine: false, starredProjects: [], starredSessions: [], starredTurns: [], @@ -24,10 +27,56 @@ function coerceStringArray(value) { } // In-memory cache — populated on first readSettings() call (server startup). -// writeSettings() updates the cache before the async disk write so subsequent +// writeSettings() refreshes it from the merged file it writes, so subsequent // reads never go back to disk. let _cache = null; +function normalizeSettings(parsed) { + return { + ...DEFAULTS, + ...parsed, + starredProjects: coerceStringArray(parsed.starredProjects), + starredSessions: coerceStringArray(parsed.starredSessions), + starredTurns: coerceStringArray(parsed.starredTurns), + starredSteps: coerceStringArray(parsed.starredSteps), + hiddenProjects: coerceStringArray(parsed.hiddenProjects), + }; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +// The on-disk file as { state: 'absent' | 'object' | 'invalid', value }. Read +// errors other than ENOENT are thrown. +function readSettingsFile() { + let raw; + try { raw = fs.readFileSync(SETTINGS_PATH, 'utf8'); } + catch (error) { + if (error.code === 'ENOENT') return { state: 'absent', value: null }; + throw error; + } + let parsed; + try { parsed = JSON.parse(raw); } catch { parsed = null; } + return isPlainObject(parsed) ? { state: 'object', value: parsed } : { state: 'invalid', value: null }; +} + +// Atomic replace that keeps a symlinked settings.json (dotfile managers): the +// tmp file is created next to the real target, owner-only, under a random name +// so a stale tmp from a crashed run never blocks the next write. +function writeSettingsAtomic(next) { + let target = SETTINGS_PATH; + try { target = fs.realpathSync(SETTINGS_PATH); } catch {} + const tmp = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + try { + fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { flag: 'wx', mode: 0o600 }); + fs.renameSync(tmp, target); + } catch (error) { + try { fs.unlinkSync(tmp); } catch {} + throw error; + } +} + function cloneSettings(s) { return { ...s, @@ -48,45 +97,74 @@ function serializeStars(s) { }; } -function readSettings({ quiet = false } = {}) { +function readSettings() { if (_cache) return cloneSettings(_cache); - let firstLoad = false; try { const parsed = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - _cache = { - ...DEFAULTS, - ...parsed, - starredProjects: coerceStringArray(parsed.starredProjects), - starredSessions: coerceStringArray(parsed.starredSessions), - starredTurns: coerceStringArray(parsed.starredTurns), - starredSteps: coerceStringArray(parsed.starredSteps), - hiddenProjects: coerceStringArray(parsed.hiddenProjects), - }; + if (isPlainObject(parsed)) { + _cache = normalizeSettings(parsed); } else { console.error(`[ccxray] settings.json at ${SETTINGS_PATH} did not parse to an object — using defaults.`); _cache = { ...DEFAULTS }; } - firstLoad = true; } catch { _cache = { ...DEFAULTS }; - firstLoad = true; - } - if (firstLoad && !quiet) { - console.log(`\x1b[90m Context HUD: ${_cache.statusLine ? 'enabled' : 'disabled'} (settings.json)\x1b[0m`); } return cloneSettings(_cache); } +// The one-line HUD state shown at proxy/launcher startup and by `ccxray hud +// status` (#692) — not by every subcommand, so readSettings() itself is silent. +function hudStatusText(enabled) { + return `Context HUD: ${enabled ? 'enabled' : 'disabled'} (settings.json)`; +} + +// `data` is the caller's full settings object (readSettings() plus its edit). +// Only the keys that differ from the cached copy are patched into the file as +// it is on disk now, so a running server never reverts a change another +// process made — e.g. `ccxray hud on` followed by a dashboard star (#696). +// A missing or non-object file is written from `data` as a whole. function writeSettings(data) { - _cache = { ...data }; + const previous = _cache; + let next = { ...data }; + try { + fs.mkdirSync(SETTINGS_DIR, { recursive: true, mode: 0o700 }); + const disk = readSettingsFile(); + if (disk.state === 'object') { + next = { ...disk.value }; + for (const key of Object.keys(data)) { + if (!previous || JSON.stringify(data[key]) !== JSON.stringify(previous[key])) next[key] = data[key]; + } + } + writeSettingsAtomic(next); + } catch (err) { + console.error('[ccxray] writeSettings failed:', err.message); + } + _cache = normalizeSettings(next); +} + +// The on-disk statusLine for `ccxray hud status`; throws on a file that is not +// a JSON object, like setStatusLineSync. +function readStatusLineSync() { + const disk = readSettingsFile(); + if (disk.state === 'invalid') throw new Error(`${SETTINGS_PATH} is not a JSON object; fix or delete it, then retry`); + return disk.state === 'object' && Boolean(disk.value.statusLine); +} + +// `ccxray hud on|off` (#692): a CLI process that exits right away, so the write +// is synchronous and atomic. Only `statusLine` changes; every other key on disk +// is kept. A settings.json that does not parse to an object is never rewritten. +function setStatusLineSync(enabled) { + const disk = readSettingsFile(); + if (disk.state === 'invalid') throw new Error(`${SETTINGS_PATH} is not a JSON object; fix or delete it, then retry`); + const next = { ...(disk.value || {}), statusLine: !!enabled }; fs.mkdirSync(SETTINGS_DIR, { recursive: true, mode: 0o700 }); - fs.writeFile(SETTINGS_PATH, JSON.stringify(data, null, 2), (err) => { - if (err) console.error('[ccxray] writeSettings failed:', err.message); - }); + writeSettingsAtomic(next); + _cache = null; + return SETTINGS_PATH; } // Test helper — reset cache so unit tests get a clean slate function _resetSettingsCache() { _cache = null; } -module.exports = { readSettings, writeSettings, serializeStars, _resetSettingsCache }; +module.exports = { readSettings, writeSettings, serializeStars, hudStatusText, setStatusLineSync, readStatusLineSync, SETTINGS_PATH, _resetSettingsCache }; diff --git a/server/setup.js b/server/setup.js index 3fc3de86..f1c121a8 100644 --- a/server/setup.js +++ b/server/setup.js @@ -30,14 +30,17 @@ const CHECK_IDS = new Set([ 'last-lock-loss', 'last-fail-open', 'coverage', 'shell-probe', ]); +// Settings that only mean something to the GCS exporter (#691). +const EXPORT_ONLY_FLAGS = ['--bucket', '--domains', '--prefix', '--cwd-allowlist']; + function usageLine() { - return 'Usage: ccxray setup [--remove | --check | --browser on|off] [--dry-run] [--yes] [--shell zsh|bash|pwsh|powershell] [--no-probe] [--bucket B] [--domains D] [--prefix P] [--cwd-allowlist L] [--retention-days N]'; + return 'Usage: ccxray setup [--remove | --check | --browser on|off] [--dry-run] [--yes] [--shell zsh|bash|pwsh|powershell] [--no-probe] [--retention-days N] [--export --bucket B --domains D [--prefix P] [--cwd-allowlist L]]'; } function parseArgs(argv) { const options = { remove: false, check: false, browser: null, dryRun: false, yes: false, - shell: null, noProbe: false, + shell: null, noProbe: false, export: false, flags: Object.create(null), }; const valued = new Set([ @@ -52,6 +55,7 @@ function parseArgs(argv) { else if (arg === '--dry-run') options.dryRun = true; else if (arg === '--yes') options.yes = true; else if (arg === '--no-probe') options.noProbe = true; + else if (arg === '--export') options.export = true; else if (valued.has(arg)) { if (seen.has(arg) || argv[i + 1] === undefined || argv[i + 1].startsWith('--')) { return { error: usageLine() }; @@ -65,8 +69,13 @@ function parseArgs(argv) { } const writeFlags = options.remove || Boolean(options.browser) - || Object.keys(options.flags).length > 0 || options.dryRun; + || Object.keys(options.flags).length > 0 || options.dryRun || options.export; if (options.check && writeFlags) return { error: usageLine() }; + // #691: export is opt-in, so its settings are only accepted with --export. + if (EXPORT_ONLY_FLAGS.some(flag => flag in options.flags) && !options.export) { + return { error: `${EXPORT_ONLY_FLAGS.filter(flag => flag in options.flags).join(', ')} requires --export\n${usageLine()}` }; + } + if (options.export && (options.remove || options.browser)) return { error: usageLine() }; if (options.remove && options.browser) return { error: usageLine() }; if (options.dryRun && options.browser) return { error: usageLine() }; if (options.noProbe && !options.check) return { error: usageLine() }; @@ -266,14 +275,17 @@ function parseRcBlock(text, powershell) { if (values.CCXRAY_EXPORT_CWD_ALLOWLIST !== undefined) { resolved.cwdAllowlist = values.CCXRAY_EXPORT_CWD_ALLOWLIST; } - if (typeof resolved.bucket !== 'string' || !resolved.bucket + // #691: a record-only block carries none of the export settings. + const recordOnly = resolved.bucket === undefined && resolved.domains === undefined + && resolved.prefix === undefined && resolved.cwdAllowlist === undefined; + if ((!recordOnly && (typeof resolved.bucket !== 'string' || !resolved.bucket || !Array.isArray(resolved.domains) || !resolved.domains.length - || typeof resolved.prefix !== 'string' || !resolved.prefix + || typeof resolved.prefix !== 'string' || !resolved.prefix)) || !Number.isFinite(resolved.retentionDays) || (resolved.cwdAllowlist !== undefined && typeof resolved.cwdAllowlist !== 'string')) { return { kind: 'malformed', reason: 'setup block was modified (does not match setup output)', text }; } - const rendererValues = { ...resolved }; + const rendererValues = recordOnly ? { retentionDays: resolved.retentionDays } : { ...resolved }; if (rendererValues.cwdAllowlist === undefined) delete rendererValues.cwdAllowlist; let rendered; try { rendered = renderRcBlock(rendererValues, powershell); } @@ -309,13 +321,17 @@ function quotePowerShell(value) { return `'${String(value).replace(/'/g, "''")}' function renderRcBlock(values, powershell, newline = '\n') { const q = powershell ? quotePowerShell : quotePosix; const lines = [START_MARKER]; - const settings = [ - ['CCXRAY_EXPORT_GCS_BUCKET', values.bucket], - ['CCXRAY_EXPORT_GCS_PREFIX', values.prefix], - ['CCXRAY_EXPORT_DOMAINS', values.domains.join(',')], - ['LOG_RETENTION_DAYS', String(values.retentionDays)], - ]; - if (values.cwdAllowlist !== undefined && values.cwdAllowlist !== '') { + // #691: without a bucket the block is record-only — wrapper, retention, and + // import opt-out, no export settings. + const settings = values.bucket === undefined + ? [['LOG_RETENTION_DAYS', String(values.retentionDays)]] + : [ + ['CCXRAY_EXPORT_GCS_BUCKET', values.bucket], + ['CCXRAY_EXPORT_GCS_PREFIX', values.prefix], + ['CCXRAY_EXPORT_DOMAINS', values.domains.join(',')], + ['LOG_RETENTION_DAYS', String(values.retentionDays)], + ]; + if (values.bucket !== undefined && values.cwdAllowlist !== undefined && values.cwdAllowlist !== '') { settings.push(['CCXRAY_EXPORT_CWD_ALLOWLIST', values.cwdAllowlist]); } settings.push(['CCXRAY_IMPORT_DISABLE', '1']); @@ -1124,7 +1140,7 @@ function fetchRemovalState(deps) { } async function prepareContext(options, deps) { - const resolution = await resolveTargets(options, deps); + const resolution = await (deps.hooks.resolveTargets || resolveTargets)(options, deps); const prepared = []; for (const target of resolution.targets) { if (target.unknown) { prepared.push(target); continue; } @@ -1285,7 +1301,7 @@ async function buildInstallChecks(context, options, deps) { checks.push(statusLine('conflict-scan', conflict.status, conflict.detail)); } for (const target of context.targets.flatMap(targetProbeDescriptors)) { - const policy = executionPolicyCheck(target, { forInstall: true, values: context.values, deps }); + const policy = executionPolicyCheck(target, { forInstall: true, values: context.installValues || context.values, deps }); if (policy) checks.push(policy); } @@ -1297,6 +1313,10 @@ async function buildInstallChecks(context, options, deps) { : deps.env.ANTHROPIC_BASE_URL ? 'ANTHROPIC_BASE_URL is set in this shell' : 'unset', )); + // #691: a record-only install has no export to preflight — no account + // filter, no ADC read, no enrollment. + if (!context.exportMode) return { checks, populationResults: [] }; + const domains = context.values.domains || []; if (!domains.length) { checks.push(statusLine('claude-account', 'unknown', 'no allowed-domain set is known')); @@ -1405,8 +1425,12 @@ async function buildCheckReport(context, options, deps) { checks.push(statusLine('coverage', coverageStatus, `coverage[${result.population.label}]: ${coverageDetail(result)}`)); } - const record = discoverCredential(context, deps); - if (record.discovery?.state === 'adc' && record.parse?.state === 'ok' + const exportConfigured = context.targets.some(hasOwnedExportBlock) + || ['CCXRAY_EXPORT_GCS_BUCKET', 'CCXRAY_EXPORT_DOMAINS', 'CCXRAY_EXPORT_GCS_KEY_FILE'].some(name => deps.env[name]); + const record = exportConfigured ? discoverCredential(context, deps) : null; + if (!record) { + checks.push(statusLine('adc', 'skipped', 'export is not configured (record-only setup)')); + } else if (record.discovery?.state === 'adc' && record.parse?.state === 'ok' && record.parse.type === 'authorized_user') { checks.push(statusLine('adc', 'ok', 'ADC authorized_user credential is readable and valid')); } else if (record.discovery?.state === 'key-file' @@ -1643,11 +1667,17 @@ function printDryRun(context, options, deps, nextEnrollment, cursorState, browse } return; } - io.log(`Would write enrollment: ${deps.exportSync.enrollmentPath(context.home)} (${enrollmentText(context.diagnostics.durableHome.enrollment)} → ${enrollmentText(nextEnrollment)})`); - io.log(`Cursor seed: ${cursorState.absent ? `create ${cursorState.path}` : `preserve ${cursorState.path}`}`); + if (nextEnrollment) { + io.log(`Would write enrollment: ${deps.exportSync.enrollmentPath(context.home)} (${enrollmentText(context.diagnostics.durableHome.enrollment)} → ${enrollmentText(nextEnrollment)})`); + io.log(`Cursor seed: ${cursorState.absent ? `create ${cursorState.path}` : `preserve ${cursorState.path}`}`); + } else io.log('Record-only: no export enrollment, cursor, or export settings will be written.'); for (const target of context.targets) { if (target.unknown || target.error || target.parsed.kind === 'malformed') continue; - const block = renderRcBlock(context.values, target.kind === 'powershell', targetNewline(target, deps)); + if (keepsExportBlock(target, options)) { + io.log(`Would leave the export setup block unchanged for ${targetAliases(target).join('; ')}.`); + continue; + } + const block = renderRcBlock(context.installValues || context.values, target.kind === 'powershell', targetNewline(target, deps)); io.log(`Would write setup block for ${targetAliases(target).join('; ')}:\n${block}`); } if (browserSetting) io.log(`Would write ${deps.path.join(context.home, 'auto-open-browser')}: ${browserSetting}`); @@ -1709,7 +1739,7 @@ async function acquireSetupLock(home, deps) { function phaseA(context, isRemove, deps) { const enrollment = deps.exportSync.readEnrollment(context.home, deps.fs); - if (!isRemove && enrollment?.state === 'invalid') { + if (!isRemove && context.exportMode && enrollment?.state === 'invalid') { return { ok: false, error: `export-enrollment.json is ${enrollment.reason}: ${enrollment.detail}. Inspect it; then either delete it (a fresh install enrolls from today) or run ccxray setup --remove to replace it with a removed tombstone.` }; } for (const target of context.targets) { @@ -1725,8 +1755,10 @@ function phaseA(context, isRemove, deps) { function changeLines(context, options, cursorState, browserSetting, nextEnrollment, deps) { const changes = []; - const enrollmentPath = deps.exportSync.enrollmentPath(context.home); - changes.push(`${enrollmentPath}: ${enrollmentText(context.diagnostics.durableHome.enrollment)} → ${enrollmentText(nextEnrollment)}`); + if (nextEnrollment) { + const enrollmentPath = deps.exportSync.enrollmentPath(context.home); + changes.push(`${enrollmentPath}: ${enrollmentText(context.diagnostics.durableHome.enrollment)} → ${enrollmentText(nextEnrollment)}`); + } if (!options.remove && cursorState) { changes.push(`${cursorState.path}: ${cursorState.absent ? 'will create (wx)' : 'will preserve existing cursor'}`); } @@ -1746,7 +1778,8 @@ function changeLines(context, options, cursorState, browserSetting, nextEnrollme changes.push(`skip malformed setup block after tombstone (cleanup incomplete): ${aliases.join('; ')}`); continue; } - const action = options.remove ? 'remove owned setup block' : 'write setup block'; + const action = options.remove ? 'remove owned setup block' + : keepsExportBlock(target, options) ? 'leave export setup block unchanged' : 'write setup block'; if (options.remove && target.parsed.kind !== 'owned') continue; for (const alias of aliases) { const logicalPath = alias.slice(alias.indexOf(': ') + 2); @@ -1762,6 +1795,10 @@ function firstOwnedValues(context) { return context.targets.filter(target => target.parsed?.kind === 'owned'); } +function hasOwnedExportBlock(target) { + return target.parsed?.kind === 'owned' && target.parsed.values.bucket !== undefined; +} + async function runBrowserMode(options, deps) { const homeRaw = deps.paths.resolveCcxrayHome(deps.env); if (!homeRaw) { @@ -1849,12 +1886,31 @@ async function runRemove(context, options, deps) { : ''; deps.logger.error(`export remains removed; shell cleanup incomplete: ${item.target.logicalPath} (${item.error?.message || 'write failed'}${recovery})`); } - } else deps.logger.log('Export removed.'); + } else deps.logger.log(context.targets.some(hasOwnedExportBlock) || context.diagnostics.durableHome.enrollment?.state === 'enrolled' ? 'Export removed.' : 'Setup removed.'); deps.logger.log('After removal, run unset -f claude in existing POSIX shells or Remove-Item Function:claude in PowerShell; starting a new terminal also clears the wrapper.'); return failures.length ? 1 : 0; } +// Plain setup never touches an owned export block (#696): it is left +// byte-for-byte as it is, so no export value, ADC read, or enrollment write is +// needed. Only --export rewrites it. +function keepsExportBlock(target, options) { + return !options.export && hasOwnedExportBlock(target); +} + async function runInstall(context, options, deps) { + // Resolve values only from the blocks this run rewrites, so a record-only + // block and an export block in different shells never conflict (#696). + // Record-only blocks carry no export fields, so --export takes those from + // the existing export blocks. + const sources = options.export + ? (context.targets.some(hasOwnedExportBlock) + ? context.targets.filter(target => target.parsed?.kind !== 'owned' || hasOwnedExportBlock(target)) + : context.targets) + : context.targets.filter(target => !keepsExportBlock(target, options)); + context.valueResolution = resolveValues(options, sources); + context.values = context.valueResolution.resolved; + const keptTargets = context.targets.filter(target => keepsExportBlock(target, options)); const { resolved, conflicts } = context.valueResolution; if (conflicts.length) { deps.logger.error(`Conflicting owned setup values: ${conflicts.join('; ')}. Pass the matching flag to rewrite every target.`); @@ -1865,14 +1921,23 @@ async function runInstall(context, options, deps) { for (const failure of valueFailures) deps.logger.error(failure); return 2; } - if (!resolved.bucket) { - deps.logger.error('first install needs --bucket'); + // #691: record-only unless --export is passed. #696: an owned export block + // left by an earlier install stays exporting, but plain setup leaves it + // untouched instead of re-enrolling (no ADC read, no enrollment write). + const exportMode = Boolean(options.export); + context.exportMode = exportMode; + if (exportMode && !resolved.bucket) { + deps.logger.error('--export needs --bucket'); return 2; } - if (!resolved.domains?.length) { - deps.logger.error('first install needs --domains'); + if (exportMode && !resolved.domains?.length) { + deps.logger.error('--export needs --domains'); return 2; } + context.installValues = exportMode ? resolved : { retentionDays: resolved.retentionDays }; + for (const target of keptTargets) { + deps.logger.log(`Export stays enabled for ${targetAliases(target).join('; ')}: its export block was left unchanged. Run ccxray setup --export to refresh it and keep exporting, or ccxray setup --remove to stop exporting.`); + } const preflight = await buildInstallChecks(context, options, deps); printChecks(preflight.checks, deps.logger); @@ -1882,7 +1947,7 @@ async function runInstall(context, options, deps) { } const firstInstall = firstOwnedValues(context).length === 0; - if (firstInstall && resolved.cwdAllowlist === undefined) { + if (exportMode && !context.targets.some(hasOwnedExportBlock) && resolved.cwdAllowlist === undefined) { deps.logger.log('\x1b[33mNotice: repository basenames export unmasked because --cwd-allowlist is not set.\x1b[0m'); } const cursorState = readCursorSeedState(context.home, deps.fs, deps.path); @@ -1897,12 +1962,12 @@ async function runInstall(context, options, deps) { const current = context.diagnostics.durableHome.enrollment === 'absent' ? null : context.diagnostics.durableHome.enrollment; - const next = nextEnrollmentInstall(current, resolved.retentionDays, deps.now()); + const next = exportMode ? nextEnrollmentInstall(current, resolved.retentionDays, deps.now()) : null; if (options.dryRun) { printDryRun(context, options, deps, next, cursorState, browserSetting); return 0; } - const changeList = changeLines(context, options, cursorState, browserSetting, next, deps); + const changeList = changeLines(context, options, exportMode ? cursorState : null, browserSetting, next, deps); if (!await showConsent(context, options, deps, changeList)) return 2; await deps.hooks.beforeLock?.({ mode: 'install', context, options }); @@ -1919,25 +1984,28 @@ async function runInstall(context, options, deps) { deps.logger.error(`${validation.error}; no files were written.`); return 1; } - enrollment = nextEnrollmentInstall(validation.enrollment, resolved.retentionDays, deps.now()); - try { deps.exportSync.writeCursor(deps.exportSync.enrollmentPath(context.home), enrollment, deps.fs); } - catch (error) { - const actual = deps.exportSync.readEnrollment(context.home, deps.fs); - deps.logger.error(`Could not write enrollment: ${error.message}. Durable enrollment on disk: ${enrollmentText(actual)}. No cursor, shell, or browser files were changed.`); - return 1; - } + if (exportMode) { + enrollment = nextEnrollmentInstall(validation.enrollment, resolved.retentionDays, deps.now()); + try { deps.exportSync.writeCursor(deps.exportSync.enrollmentPath(context.home), enrollment, deps.fs); } + catch (error) { + const actual = deps.exportSync.readEnrollment(context.home, deps.fs); + deps.logger.error(`Could not write enrollment: ${error.message}. Durable enrollment on disk: ${enrollmentText(actual)}. No cursor, shell, or browser files were changed.`); + return 1; + } - const cursorBeforeSeed = readCursorSeedState(context.home, deps.fs, deps.path); - if (cursorBeforeSeed.error) { - partialFailures.push({ file: cursorBeforeSeed.path, error: cursorBeforeSeed.error }); - } else if (cursorBeforeSeed.absent) { - const seed = cursorSeed(context.home, enrollment, deps); - if (seed.kind === 'failed') partialFailures.push({ file: seed.path, error: seed.error }); + const cursorBeforeSeed = readCursorSeedState(context.home, deps.fs, deps.path); + if (cursorBeforeSeed.error) { + partialFailures.push({ file: cursorBeforeSeed.path, error: cursorBeforeSeed.error }); + } else if (cursorBeforeSeed.absent) { + const seed = cursorSeed(context.home, enrollment, deps); + if (seed.kind === 'failed') partialFailures.push({ file: seed.path, error: seed.error }); + } } for (let index = 0; index < context.targets.length; index++) { const target = context.targets[index]; - const bytes = renderTargetRc(target, resolved, false, deps); + if (keepsExportBlock(target, options)) continue; + const bytes = renderTargetRc(target, context.installValues, false, deps); const result = commitRcTarget(target, bytes, deps); if (result.kind !== 'committed') partialFailures.push({ file: target.logicalPath, error: result.error }); else await deps.hooks.afterTargetCommit?.({ mode: 'install', target, index, context }); @@ -1953,11 +2021,13 @@ async function runInstall(context, options, deps) { if (partialFailures.length) { for (const failure of partialFailures) { - deps.logger.error(`enrollment is ACTIVE (state: enrolled) — an already-running exporter with export settings may upload; ${failure.file} was not updated. Re-run ccxray setup, or ccxray setup --remove to un-enroll. (${failure.error?.message || 'write failed'})`); + if (exportMode) deps.logger.error(`enrollment is ACTIVE (state: enrolled) — an already-running exporter with export settings may upload; ${failure.file} was not updated. Re-run ccxray setup --export, or ccxray setup --remove to un-enroll. (${failure.error?.message || 'write failed'})`); + else deps.logger.error(`setup incomplete: ${failure.file} was not updated. Re-run ccxray setup. (${failure.error?.message || 'write failed'})`); } return 1; } deps.logger.log('ccxray setup complete. Restart Claude Code sessions to activate the wrapper.'); + if (!exportMode && !keptTargets.length) deps.logger.log('Recording locally only; export is off. Enable it with ccxray setup --export --bucket B --domains D.'); return 0; } @@ -1966,7 +2036,19 @@ async function runSetup(argv = process.argv.slice(3), overrides = {}) { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { deps.logger.log(`${usageLine()} +Modes: + ccxray setup Record-only (default): install the Claude shell wrapper + and local recording settings. Writes no export + settings and needs no bucket, domains, or gcloud ADC. + ccxray setup --export --bucket B --domains D + Also enroll in summary export to GCS (needs gcloud + Application Default Credentials). Re-running plain + setup leaves an existing export block unchanged (it + keeps exporting); --export refreshes it and + --remove stops it. + Options: + --export Enable summary export (requires --bucket and --domains) --remove Remove enrollment and the owned shell wrapper --check Inspect setup and export diagnostics without changes --browser on|off Set automatic dashboard opening preference @@ -1974,10 +2056,10 @@ Options: --yes Accept setup consent without prompting --shell zsh|bash|pwsh|powershell Select the shell startup file --no-probe Skip shell startup probes (only with --check) - --bucket B GCS bucket for exported summaries - --domains D Comma-separated allowed account email domains - --prefix P GCS object prefix - --cwd-allowlist L Comma-separated working directory basenames + --bucket B GCS bucket for exported summaries (with --export) + --domains D Comma-separated allowed account email domains (with --export) + --prefix P GCS object prefix (with --export) + --cwd-allowlist L Comma-separated working directory basenames (with --export) --retention-days N Retention in days (1–3650; default 30) --help, -h Show this help`); return 0; diff --git a/test/anthropic-print-hud.e2e.test.js b/test/anthropic-print-hud.e2e.test.js index def640a0..4fd78999 100644 --- a/test/anthropic-print-hud.e2e.test.js +++ b/test/anthropic-print-hud.e2e.test.js @@ -226,6 +226,8 @@ describe('Anthropic Context HUD and Claude print-mode header', () => { fs.mkdirSync(path.join(dataHome, 'logs'), { recursive: true }); fs.writeFileSync(path.join(dataHome, 'logs', 'index.ndjson'), ''); fs.mkdirSync(userHome, { recursive: true }); + // #692: the HUD is off by default; this suite exercises an explicit opt-in. + fs.writeFileSync(path.join(dataHome, 'settings.json'), JSON.stringify({ statusLine: true })); fs.writeFileSync(path.join(dataHome, 'pricing-cache.json'), JSON.stringify({ fetchedAt: Date.now(), pricing: {}, diff --git a/test/cli-output.test.js b/test/cli-output.test.js index a9cfdeb3..52d951f7 100644 --- a/test/cli-output.test.js +++ b/test/cli-output.test.js @@ -36,14 +36,17 @@ it('documents current status output and the version flags', () => { it('setup help documents every flag and succeeds; invalid arguments exit 2', () => { const r = cli(['setup', '--help']); assert.equal(r.status, 0, r.stderr); - for (const flag of ['remove', 'check', 'browser', 'dry-run', 'yes', 'shell', 'no-probe', 'bucket', 'domains', 'prefix', 'cwd-allowlist', 'retention-days']) { + for (const flag of ['export', 'remove', 'check', 'browser', 'dry-run', 'yes', 'shell', 'no-probe', 'bucket', 'domains', 'prefix', 'cwd-allowlist', 'retention-days']) { assert.match(r.stdout, new RegExp(`--${flag}[^\\n]+\\s{2,}\\S`)); } + // #691: both modes are documented — record-only default and opt-in export. + assert.match(r.stdout, /ccxray setup\s+Record-only \(default\)/); + assert.match(r.stdout, /ccxray setup --export --bucket B --domains D/); assert.match(r.stdout, /--no-probe[^\n]*--check/); assert.equal(cli(['setup', '--invalid']).status, 2); }); it('utility commands do not print the Context HUD startup line', () => { - for (const args of [['setup', '--check', '--no-probe'], ['status'], ['open']]) { + for (const args of [['setup', '--check', '--no-probe'], ['status'], ['open'], ['usage'], ['--version']]) { const r = cli(args); assert.notEqual(r.status, null, r.stderr); assert.doesNotMatch(r.stderr, /bootstrap-failed|Cannot find module/); @@ -51,8 +54,8 @@ it('utility commands do not print the Context HUD startup line', () => { } }); -it('keeps the HUD startup log for normal settings loading', () => { - const r = cli(["require('./server/settings').readSettings()"], '-e'); +it('loading settings is silent and the HUD defaults to off (#692)', () => { + const r = cli(["console.log(JSON.stringify(require('./server/settings').readSettings().statusLine))"], '-e'); assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /Context HUD: enabled \(settings.json\)/); + assert.equal(r.stdout, 'false\n'); }); diff --git a/test/forward-proxy.test.js b/test/forward-proxy.test.js index 2393b5f2..eb0dd1a3 100644 --- a/test/forward-proxy.test.js +++ b/test/forward-proxy.test.js @@ -13,6 +13,8 @@ const { parseSSEText, normalizeOpenAIResponseSummary, } = require('../server/forward'); +// Captured before any test toggles the module-level flag. +const initialStatusLineEnabled = getStatusLineEnabled(); describe('resolveProxyAgent', () => { it('returns null when no proxy env vars are set', () => { @@ -168,8 +170,8 @@ describe('describeUpstreamError', () => { describe('statusLineEnabled flag', () => { beforeEach(() => setStatusLineEnabled(true)); - it('defaults to true', () => { - assert.equal(getStatusLineEnabled(), true); + it('defaults to false until settings.json enables it (#692)', () => { + assert.equal(initialStatusLineEnabled, false); }); it('setStatusLineEnabled(false) disables the flag', () => { diff --git a/test/hud.test.js b/test/hud.test.js new file mode 100644 index 00000000..3324e833 --- /dev/null +++ b/test/hud.test.js @@ -0,0 +1,204 @@ +'use strict'; +// #692: the Context HUD is off by default, `ccxray hud on|off|status` toggles +// it in the same settings.json the dashboard writes, and the HUD line is +// printed only by proxy/launcher startup and `hud status`. +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { spawn, spawnSync } = require('node:child_process'); + +const SERVER = path.join(__dirname, '..', 'server', 'index.js'); + +function makeHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-hud-')); + fs.mkdirSync(path.join(home, 'logs'), { recursive: true, mode: 0o700 }); + fs.chmodSync(home, 0o700); + fs.writeFileSync(path.join(home, 'logs', 'index.ndjson'), ''); + return home; +} + +function isolatedEnv(home) { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(CCXRAY_|ANTHROPIC_|OPENAI_|CHATGPT_|XAI_|GROK_)/.test(key)) delete env[key]; + } + delete env.LOGS_DIR; + delete env.STORAGE_BACKEND; + delete env.PROXY_PORT; + return { + ...env, + HOME: home, USERPROFILE: home, CCXRAY_HOME: home, LOGS_DIR: path.join(home, 'logs'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), CODEX_HOME: path.join(home, '.codex'), + CLOUDSDK_CONFIG: home, CCXRAY_PRICING_CACHE: path.join(home, 'pricing-cache.json'), + CCXRAY_EXPORT_DISABLE: '1', CCXRAY_IMPORT_DISABLE: '1', + RESTORE_DAYS: '0', LOG_RETENTION_DAYS: '0', BROWSER: 'none', TZ: 'UTC', + }; +} + +function cli(home, args) { + return spawnSync(process.execPath, [SERVER, ...args], { + encoding: 'utf8', timeout: 15000, env: isolatedEnv(home), + }); +} + +function settingsFile(home) { return path.join(home, 'settings.json'); } +function readSettingsFile(home) { return JSON.parse(fs.readFileSync(settingsFile(home), 'utf8')); } + +function withHome(run) { + const home = makeHome(); + try { return run(home); } finally { fs.rmSync(home, { recursive: true, force: true }); } +} + +describe('ccxray hud', () => { + it('defaults to disabled on a fresh home and status writes nothing', () => withHome(home => { + const r = cli(home, ['hud', 'status']); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'Context HUD: disabled (settings.json)\n'); + assert.equal(fs.existsSync(settingsFile(home)), false); + })); + + it('a settings.json without statusLine is disabled; an explicit true stays enabled', () => withHome(home => { + fs.writeFileSync(settingsFile(home), JSON.stringify({ starredSessions: ['synthetic-session'] })); + assert.match(cli(home, ['hud', 'status']).stdout, /Context HUD: disabled/); + fs.writeFileSync(settingsFile(home), JSON.stringify({ statusLine: true })); + assert.match(cli(home, ['hud', 'status']).stdout, /Context HUD: enabled/); + })); + + it('on/off persist statusLine and keep every other setting', () => withHome(home => { + fs.writeFileSync(settingsFile(home), JSON.stringify({ starredSessions: ['synthetic-session'], hiddenProjects: ['p'] })); + let r = cli(home, ['hud', 'on']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^Context HUD: enabled \(settings\.json\)\n/); + assert.match(r.stdout, /dashboard toggle changes it live/); + assert.deepEqual(readSettingsFile(home), { starredSessions: ['synthetic-session'], hiddenProjects: ['p'], statusLine: true }); + assert.match(cli(home, ['hud', 'status']).stdout, /Context HUD: enabled/); + + r = cli(home, ['hud', 'off']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^Context HUD: disabled/); + assert.equal(readSettingsFile(home).statusLine, false); + assert.deepEqual(readSettingsFile(home).starredSessions, ['synthetic-session']); + assert.match(cli(home, ['hud', 'status']).stdout, /Context HUD: disabled/); + assert.deepEqual(fs.readdirSync(home).filter(name => name.includes('.tmp-')), []); + })); + + it('hud on creates settings.json on a fresh home', () => withHome(home => { + assert.equal(cli(home, ['hud', 'on']).status, 0); + assert.deepEqual(readSettingsFile(home), { statusLine: true }); + })); + + it('prints usage for no argument and --help, and exits 1 on an unknown argument', () => withHome(home => { + for (const args of [['hud'], ['hud', '--help'], ['hud', '-h']]) { + const r = cli(home, args); + assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); + assert.match(r.stdout, /^Usage: ccxray hud on\|off\|status/); + } + for (const args of [['hud', 'enable'], ['hud', 'on', 'extra'], ['hud', '--bogus']]) { + const r = cli(home, args); + assert.equal(r.status, 1, args.join(' ')); + assert.match(r.stderr, /unknown hud argument/); + assert.match(r.stderr, /Usage: ccxray hud on\|off\|status/); + } + assert.equal(fs.existsSync(settingsFile(home)), false); + })); + + it('refuses to rewrite a settings.json that is not a JSON object', () => withHome(home => { + fs.writeFileSync(settingsFile(home), '{not json'); + const r = cli(home, ['hud', 'on']); + assert.equal(r.status, 1); + assert.match(r.stderr, /not a JSON object/); + assert.equal(fs.readFileSync(settingsFile(home), 'utf8'), '{not json'); + })); + + it('status reports a settings.json that is not a JSON object and exits non-zero', () => withHome(home => { + for (const body of ['{not json', '[]']) { + fs.writeFileSync(settingsFile(home), body); + const r = cli(home, ['hud', 'status']); + assert.equal(r.status, 1, body); + assert.match(r.stderr, /not a JSON object/); + assert.doesNotMatch(r.stdout, /Context HUD/); + assert.equal(fs.readFileSync(settingsFile(home), 'utf8'), body); + } + })); + + it('on writes through a symlinked settings.json, owner-only, leaving no tmp file', { skip: process.platform === 'win32' }, () => withHome(home => { + const managed = path.join(home, 'dotfiles', 'ccxray-settings.json'); + fs.mkdirSync(path.dirname(managed)); + fs.writeFileSync(managed, JSON.stringify({ starredSessions: ['synthetic-session'] })); + fs.symlinkSync(managed, settingsFile(home)); + const r = cli(home, ['hud', 'on']); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.lstatSync(settingsFile(home)).isSymbolicLink(), 'symlink must survive'); + assert.deepEqual(JSON.parse(fs.readFileSync(managed, 'utf8')), { starredSessions: ['synthetic-session'], statusLine: true }); + assert.equal(fs.statSync(managed).mode & 0o777, 0o600); + assert.deepEqual(fs.readdirSync(path.dirname(managed)), ['ccxray-settings.json']); + assert.ok(!fs.readdirSync(home).some(name => name.includes('.tmp-'))); + })); + + it('is listed in the global help', () => withHome(home => { + assert.match(cli(home, ['--help']).stdout, /hud on\|off\|status\s+\S/); + })); +}); + +describe('Context HUD line placement', () => { + it('utility subcommands do not print it', () => withHome(home => { + for (const args of [['status'], ['usage'], ['--version'], ['open'], ['setup', '--check', '--no-probe'], ['import', '--help']]) { + const r = cli(home, args); + assert.notEqual(r.status, null, `${args.join(' ')} timed out`); + assert.doesNotMatch(r.stdout + r.stderr, /Context HUD:/, args.join(' ')); + } + })); + + it('standalone proxy startup prints it, and the dashboard toggle persists over the new default', async () => { + const home = makeHome(); + const port = await new Promise(resolve => { + const srv = http.createServer().listen(0, '127.0.0.1', () => { + const p = srv.address().port; srv.close(() => resolve(p)); + }); + }); + const child = spawn(process.execPath, [SERVER, '--port', String(port), '--no-browser'], { + env: isolatedEnv(home), stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout.on('data', chunk => { out += chunk; }); + child.stderr.on('data', chunk => { out += chunk; }); + const request = (method, body) => new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: '/_api/settings', method, + headers: body ? { 'Content-Type': 'application/json' } : {} }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }); + req.on('error', reject); + req.end(body); + }); + try { + const started = Date.now(); + while (!/Context HUD:/.test(out)) { + assert.ok(Date.now() - started < 15000, `no HUD line in startup output:\n${out}`); + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.match(out, /Context HUD: disabled \(settings\.json\)/); + assert.equal(out.match(/Context HUD:/g).length, 1, out); + + const before = await request('GET'); + assert.equal(before.status, 200, before.body); + assert.equal(JSON.parse(before.body).statusLine, false); + const toggled = await request('POST', JSON.stringify({ statusLine: true })); + assert.equal(toggled.status, 200, toggled.body); + const waitStart = Date.now(); + while (!(fs.existsSync(settingsFile(home)) && readSettingsFile(home).statusLine === true)) { + assert.ok(Date.now() - waitStart < 5000, 'dashboard toggle was not persisted'); + await new Promise(resolve => setTimeout(resolve, 25)); + } + assert.equal(JSON.parse((await request('GET')).body).statusLine, true); + } finally { + child.kill('SIGTERM'); + await new Promise(resolve => { if (child.exitCode !== null) resolve(); else child.on('exit', resolve); }); + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/test/settings-write.test.js b/test/settings-write.test.js new file mode 100644 index 00000000..a8596df7 --- /dev/null +++ b/test/settings-write.test.js @@ -0,0 +1,80 @@ +'use strict'; +// #696: server/settings.js writes. A running server must not revert a change +// another process (`ccxray hud on|off`) made, writes keep a symlinked +// settings.json, and the tmp file is owner-only under a random name. +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-settings-write-')); +const prevHome = process.env.CCXRAY_HOME; +let settings; + +function file() { return path.join(home, 'settings.json'); } +function readFile(target = file()) { return JSON.parse(fs.readFileSync(target, 'utf8')); } + +describe('settings writes', () => { + before(() => { + process.env.CCXRAY_HOME = home; + delete require.cache[require.resolve('../server/settings')]; + settings = require('../server/settings'); + assert.equal(settings.SETTINGS_PATH, file()); + }); + after(() => { + if (prevHome === undefined) delete process.env.CCXRAY_HOME; + else process.env.CCXRAY_HOME = prevHome; + delete require.cache[require.resolve('../server/settings')]; + fs.rmSync(home, { recursive: true, force: true }); + }); + beforeEach(() => { + for (const name of fs.readdirSync(home)) fs.rmSync(path.join(home, name), { recursive: true, force: true }); + settings._resetSettingsCache(); + }); + + it('a dashboard write after `hud on` keeps the CLI statusLine', () => { + fs.writeFileSync(file(), JSON.stringify({ statusLine: false, starredSessions: [] })); + const cached = settings.readSettings(); + assert.equal(cached.statusLine, false); + // Another process (`ccxray hud on`) flips the file; this cache is stale. + fs.writeFileSync(file(), JSON.stringify({ statusLine: true, starredSessions: [], extra: 'kept' })); + + settings.writeSettings({ ...settings.readSettings(), starredSessions: ['synthetic-session'] }); + assert.deepEqual(readFile(), { statusLine: true, starredSessions: ['synthetic-session'], extra: 'kept' }); + assert.equal(settings.readSettings().statusLine, true, 'cache refreshed from disk'); + + // The dashboard toggle still changes it. + settings.writeSettings({ ...settings.readSettings(), statusLine: false }); + assert.equal(readFile().statusLine, false); + assert.deepEqual(readFile().starredSessions, ['synthetic-session']); + }); + + it('writes keep a symlinked settings.json', { skip: process.platform === 'win32' }, () => { + const managed = path.join(home, 'managed', 'settings.json'); + fs.mkdirSync(path.dirname(managed)); + fs.writeFileSync(managed, JSON.stringify({ hiddenProjects: ['p'] })); + fs.symlinkSync(managed, file()); + + settings.setStatusLineSync(true); + assert.ok(fs.lstatSync(file()).isSymbolicLink()); + assert.deepEqual(readFile(managed), { hiddenProjects: ['p'], statusLine: true }); + + settings.writeSettings({ ...settings.readSettings(), starredTurns: ['t'] }); + assert.ok(fs.lstatSync(file()).isSymbolicLink()); + assert.deepEqual(readFile(managed).starredTurns, ['t']); + assert.equal(readFile(managed).statusLine, true); + assert.deepEqual(fs.readdirSync(path.dirname(managed)), ['settings.json']); + }); + + it('a stale pid-named tmp file does not block a write, and the file is 0600', () => { + fs.writeFileSync(`${file()}.tmp-${process.pid}`, 'stale'); + settings.setStatusLineSync(true); + assert.equal(readFile().statusLine, true); + settings.writeSettings({ ...settings.readSettings(), starredSteps: ['s'] }); + assert.deepEqual(readFile().starredSteps, ['s']); + if (process.platform !== 'win32') assert.equal(fs.statSync(file()).mode & 0o777, 0o600); + const leftovers = fs.readdirSync(home).filter(name => name.includes('.tmp-')); + assert.deepEqual(leftovers, [`settings.json.tmp-${process.pid}`]); + }); +}); diff --git a/test/setup.test.js b/test/setup.test.js index 0b30a8d2..a7eb4749 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -235,7 +235,7 @@ function mockedPowerShellDeps(fixture, profile, overrides = {}) { } function installArgs(extra = []) { - return ['--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', ...extra]; + return ['--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', ...extra]; } function installTargetFromFile(fixture, file, label = 'second target') { @@ -530,7 +530,7 @@ test('validated setup values reject shell injection and preserve field precedenc assert.ok(disagree.conflicts.some(item => item.startsWith('bucket:'))); const flagged = setup.resolveValues( - setup.parseArgs(['--shell', 'bash', '--bucket', 'fixture-bucket-03']).options, + setup.parseArgs(['--shell', 'bash', '--export', '--bucket', 'fixture-bucket-03']).options, [makeOwned('/fixture/bashrc', 'fixture-bucket-01'), makeOwned('/fixture/pwsh-profile', 'fixture-bucket-02')], ); assert.equal(flagged.resolved.bucket, 'fixture-bucket-03'); @@ -540,7 +540,7 @@ test('validated setup values reject shell injection and preserve field precedenc assert.equal(inherited.resolved.bucket, 'fixture-bucket-01'); assert.equal(inherited.resolved.retentionDays, 21); const cleared = setup.resolveValues( - setup.parseArgs(['--shell', 'bash', '--cwd-allowlist', '']).options, + setup.parseArgs(['--shell', 'bash', '--export', '--cwd-allowlist', '']).options, [makeOwned('/fixture/bashrc', 'fixture-bucket-01')], ); assert.equal(cleared.resolved.cwdAllowlist, ''); @@ -654,7 +654,7 @@ async function assertRawSetupTarget(shell, variable, filename) { const log = makeLogger(); const deps = depsFor(fixture, { env: { [variable]: rawBase }, logger: log }); const installed = await setup.runSetup([ - '--shell', shell, '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', + '--shell', shell, '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', ], deps); assert.equal(installed, 0, log.lines.join('\n')); assert.ok(fs.readFileSync(physicalRc).includes(Buffer.from(setup.START_MARKER))); @@ -729,7 +729,7 @@ test('strict ownership rejects injected lines inside marked blocks', async () => ? mockedPowerShellDeps(fixture, file, { logger: log }) : depsFor(fixture, { logger: log }); const installArgsForShell = [ - '--shell', shellCase.shell, '--bucket', 'fixture-bucket-01', + '--shell', shellCase.shell, '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', ]; const installed = await setup.runSetup(installArgsForShell, deps); @@ -768,7 +768,7 @@ test('modified wrapper revisions are malformed and show the manual recovery inst assert.equal(parsed.reason, 'setup block was modified (does not match setup output)'); const log = makeLogger(); const install = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', ], depsFor(fixture, { logger: log })); assert.equal(install, 1, log.lines.join('\n')); assert.deepEqual(fs.readFileSync(file), Buffer.from(original)); @@ -799,7 +799,7 @@ test('owned block round-trip table preserves foreign bytes across encoding and n fs.writeFileSync(file, original); const first = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', ], depsFor(fixture)); assert.equal(first, 0, `${encoding.name}/${JSON.stringify(newline)}/${trailingNewline}/${hasPrelude}`); const current = fs.readFileSync(file); @@ -810,7 +810,7 @@ test('owned block round-trip table preserves foreign bytes across encoding and n assert.ok(current.subarray(0, preservedPrelude.length).equals(preservedPrelude), 'foreign prelude bytes changed'); const replacement = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-03', '--domains', 'example.test', '--yes', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-03', '--domains', 'example.test', '--yes', ], depsFor(fixture)); assert.equal(replacement, 0); assert.equal(setup.parseRcBlock(setup.decodeRc(fs.readFileSync(file)).text, false).values.bucket, 'fixture-bucket-03'); @@ -927,7 +927,7 @@ test('dry-run output lists every logical alias and prints only the generated blo diagnostics: { durableHome: { enrollment: 'absent' } }, }; const deps = depsFor(fixture, { logger: log }); - setup.printDryRun(context, setup.parseArgs(['--dry-run']).options, deps, + setup.printDryRun(context, setup.parseArgs(['--dry-run', '--export']).options, deps, { state: 'enrolled', enrolledAt: '2026-10-14T00:00:00.000Z', retentionDays: 30 }, { absent: true, path: path.join(setupHome(fixture), 'export-cursor.json') }, null); const output = log.lines.join('\n'); @@ -958,19 +958,19 @@ test('install edits a symlink target while preserving the startup-file link', as }); }); -test('first install requires explicit bucket and domains and never takes values from ambient env', async () => { +test('--export requires explicit bucket and domains and never takes values from ambient env', async () => { await withFixture(async fixture => { const log = makeLogger(); - let result = await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture, { logger: log })); + let result = await setup.runSetup(['--shell', 'bash', '--export', '--yes'], depsFor(fixture, { logger: log })); assert.equal(result, 2); - assert.ok(log.lines.some(line => line.includes('first install needs --bucket'))); + assert.ok(log.lines.some(line => line.includes('--export needs --bucket'))); assert.equal(fs.existsSync(exportSync.enrollmentPath(setupHome(fixture))), false); const next = makeLogger(); - result = await setup.runSetup(['--shell', 'bash', '--bucket', 'fixture-bucket-01', '--yes'], + result = await setup.runSetup(['--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--yes'], depsFor(fixture, { logger: next })); assert.equal(result, 2); - assert.ok(next.lines.some(line => line.includes('first install needs --domains'))); + assert.ok(next.lines.some(line => line.includes('--export needs --domains'))); assert.equal(fs.existsSync(rcFile(fixture)), false); }); }); @@ -1029,7 +1029,7 @@ test('successful install and re-run keep values, retention, cursor, and the abse const overrideLog = makeLogger(); result = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-03', '--domains', 'EXAMPLE.TEST', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-03', '--domains', 'EXAMPLE.TEST', '--retention-days', '11', '--yes', ], depsFor(fixture, { logger: overrideLog })); assert.equal(result, 0, overrideLog.lines.join('\n')); @@ -1056,6 +1056,208 @@ test('first install without an allowlist warns and re-run keeps that field absen }); }); +// #691: export is opt-in; plain `ccxray setup` is record-only. +function spyCredentials() { + const calls = []; + const fail = name => () => { calls.push(name); throw new Error(`credentials.${name} must not run in record-only setup`); }; + return { + calls, + api: { discoverCredentials: fail('discoverCredentials'), describeCredentials: fail('describeCredentials'), credentialText: fail('credentialText') }, + }; +} + +function plantGcloud(fixture) { + const marker = path.join(fixture.paths.root, 'gcloud-invoked'); + const bin = path.join(fixture.paths.bin, 'gcloud'); + fs.writeFileSync(bin, `#!/bin/sh\necho invoked > '${marker}'\n`, { mode: 0o755 }); + return marker; +} + +test('default setup is record-only: wrapper and retention, no export config, no ADC or gcloud', async () => { + await withFixture(async fixture => { + const log = makeLogger(); + const credentials = spyCredentials(); + const marker = plantGcloud(fixture); + const spawned = []; + const result = await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture, { + logger: log, + credentials: credentials.api, + spawn: (file, args, options) => { spawned.push(file); return childProcess.spawn(file, args, options); }, + })); + assert.equal(result, 0, log.lines.join('\n')); + const rc = fs.readFileSync(rcFile(fixture), 'utf8'); + assert.match(rc, /ccxray-wrapper-v1/); + assert.match(rc, /LOG_RETENTION_DAYS='30'/); + assert.match(rc, /CCXRAY_IMPORT_DISABLE='1'/); + assert.doesNotMatch(rc, /CCXRAY_EXPORT_/); + assert.equal(fs.existsSync(exportSync.enrollmentPath(setupHome(fixture))), false); + assert.equal(fs.existsSync(path.join(setupHome(fixture), 'export-cursor.json')), false); + assert.deepEqual(credentials.calls, []); + assert.equal(fs.existsSync(marker), false, 'gcloud must not run'); + assert.ok(!spawned.some(file => path.basename(String(file)).startsWith('gcloud'))); + assert.ok(!log.lines.some(line => /^(adc|enrollment|claude-account|account-count):/.test(line)), log.lines.join('\n')); + assert.ok(log.lines.some(line => line.includes('export is off'))); + + // The record-only block is owned: a re-run is a byte-identical rewrite. + const rerun = await setup.runSetup(['--shell', 'bash', '--retention-days', '9', '--yes'], depsFor(fixture, { + credentials: credentials.api, + })); + assert.equal(rerun, 0); + const rerunRc = fs.readFileSync(rcFile(fixture), 'utf8'); + assert.match(rerunRc, /LOG_RETENTION_DAYS='9'/); + assert.equal(rerunRc.split(setup.START_MARKER).length, 2); + assert.equal(fs.existsSync(exportSync.enrollmentPath(setupHome(fixture))), false); + + // --check on a record-only install does not read ADC or fail on it. + const checkLog = makeLogger(); + const check = await setup.runSetup(['--check', '--shell', 'bash', '--no-probe'], depsFor(fixture, { + logger: checkLog, credentials: credentials.api, + })); + assert.equal(check, 0, checkLog.lines.join('\n')); + assert.ok(checkLog.lines.some(line => line.startsWith('adc: skipped'))); + assert.deepEqual(credentials.calls, []); + }); +}); + +test('record-only dry run names no enrollment or cursor', async () => { + await withFixture(async fixture => { + const log = makeLogger(); + const result = await setup.runSetup(['--shell', 'bash', '--dry-run', '--yes'], depsFor(fixture, { + logger: log, credentials: spyCredentials().api, + })); + assert.equal(result, 0, log.lines.join('\n')); + const text = log.lines.join('\n'); + assert.doesNotMatch(text, /Would write enrollment|Cursor seed|CCXRAY_EXPORT_/); + assert.match(text, /Record-only/); + assert.equal(fs.existsSync(rcFile(fixture)), false); + }); +}); + +test('export settings without --export are refused before anything is written', async () => { + await withFixture(async fixture => { + for (const flags of [['--bucket', 'fixture-bucket-01'], ['--domains', 'example.test'], ['--prefix', 'p'], ['--cwd-allowlist', 'a']]) { + const log = makeLogger(); + const result = await setup.runSetup(['--shell', 'bash', ...flags, '--yes'], depsFor(fixture, { logger: log })); + assert.equal(result, 2, flags.join(' ')); + assert.ok(log.lines.some(line => line.includes(`${flags[0]} requires --export`)), log.lines.join('\n')); + } + assert.ok(setup.parseArgs(['--export', '--remove']).error); + assert.ok(setup.parseArgs(['--export', '--browser', 'on']).error); + assert.ok(setup.parseArgs(['--export', '--check']).error); + assert.equal(fs.existsSync(rcFile(fixture)), false); + }); +}); + +test('plain setup leaves an existing export block untouched: no ADC, gcloud, or enrollment write', async () => { + await withFixture(async fixture => { + // An install made by 2.x: its owned block carries the export settings. + const original = 'export PATH=/synthetic/bin\n' + setup.renderRcBlock({ + bucket: 'fixture-bucket-01', domains: ['example.test'], prefix: 'summaries', retentionDays: 30, + }, false); + fs.writeFileSync(rcFile(fixture), original); + const enrollmentFile = exportSync.enrollmentPath(setupHome(fixture)); + const enrollmentBytes = `${JSON.stringify({ synthetic: 'enrollment-sentinel' })}\n`; + fs.writeFileSync(enrollmentFile, enrollmentBytes, { mode: 0o600 }); + const credentials = spyCredentials(); + const marker = plantGcloud(fixture); + const spawned = []; + const writes = []; + const exportSyncSpy = { + writeCursor: (...args) => { writes.push('writeCursor'); return exportSync.writeCursor(...args); }, + }; + const log = makeLogger(); + const result = await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture, { + logger: log, + credentials: credentials.api, + exportSync: exportSyncSpy, + spawn: (file, args, options) => { spawned.push(file); return childProcess.spawn(file, args, options); }, + })); + assert.equal(result, 0, log.lines.join('\n')); + assert.equal(fs.readFileSync(rcFile(fixture), 'utf8'), original, 'export block must be byte-for-byte unchanged'); + assert.equal(fs.readFileSync(enrollmentFile, 'utf8'), enrollmentBytes, 'enrollment must not be rewritten'); + assert.equal(fs.existsSync(path.join(setupHome(fixture), 'export-cursor.json')), false); + assert.deepEqual(writes, []); + assert.deepEqual(credentials.calls, []); + assert.equal(fs.existsSync(marker), false, 'gcloud must not run'); + assert.ok(!spawned.some(file => path.basename(String(file)).startsWith('gcloud'))); + assert.ok(!log.lines.some(line => /^(adc|enrollment|claude-account|account-count):/.test(line)), log.lines.join('\n')); + assert.ok(log.lines.some(line => line.includes('leave export setup block unchanged')), log.lines.join('\n')); + const notices = log.lines.filter(line => line.startsWith('Export stays enabled')); + assert.equal(notices.length, 1); + assert.match(notices[0], /ccxray setup --export/); + assert.match(notices[0], /ccxray setup --remove/); + assert.ok(!log.lines.some(line => line.includes('export is off'))); + + const dry = makeLogger(); + assert.equal(await setup.runSetup(['--shell', 'bash', '--dry-run', '--yes'], depsFor(fixture, { + logger: dry, credentials: credentials.api, + })), 0); + assert.ok(dry.lines.some(line => line.includes('Would leave the export setup block unchanged')), dry.lines.join('\n')); + assert.doesNotMatch(dry.lines.join('\n'), /Would write setup block|Would write enrollment/); + assert.deepEqual(credentials.calls, []); + }); +}); + +test('mixed targets: plain setup writes the record-only shell and leaves the export shell; --export upgrades both', async () => { + await withFixture(async fixture => { + const bashRc = rcFile(fixture, 'bash'); + const zshRc = rcFile(fixture, 'zsh'); + fs.mkdirSync(path.dirname(zshRc), { recursive: true }); + fs.writeFileSync(bashRc, setup.renderRcBlock({ retentionDays: 30 }, false)); + const exportBlock = setup.renderRcBlock({ + bucket: 'fixture-bucket-01', domains: ['example.test'], prefix: 'summaries', retentionDays: 30, + }, false); + fs.writeFileSync(zshRc, exportBlock); + const hooks = { + resolveTargets: async () => ({ + targets: [ + { shell: 'bash', kind: 'posix', logicalPath: bashRc, label: 'bash' }, + { shell: 'zsh', kind: 'posix', logicalPath: zshRc, label: 'zsh' }, + ], + issues: [], + notices: [], + }), + }; + const credentials = spyCredentials(); + const log = makeLogger(); + const result = await setup.runSetup(['--retention-days', '12', '--yes'], depsFor(fixture, { + logger: log, credentials: credentials.api, hooks, + })); + assert.equal(result, 0, log.lines.join('\n')); + const bash = fs.readFileSync(bashRc, 'utf8'); + assert.match(bash, /LOG_RETENTION_DAYS='12'/); + assert.doesNotMatch(bash, /CCXRAY_EXPORT_/); + assert.equal(fs.readFileSync(zshRc, 'utf8'), exportBlock); + assert.deepEqual(credentials.calls, []); + assert.equal(fs.existsSync(exportSync.enrollmentPath(setupHome(fixture))), false); + assert.ok(!log.lines.some(line => /needs --bucket|Conflicting/.test(line)), log.lines.join('\n')); + assert.equal(log.lines.filter(line => line.startsWith('Export stays enabled for zsh')).length, 1, log.lines.join('\n')); + + // --export takes the bucket and domains from the existing export block. + const upgrade = makeLogger(); + const upgraded = await setup.runSetup(['--export', '--yes'], depsFor(fixture, { logger: upgrade, hooks })); + assert.equal(upgraded, 0, upgrade.lines.join('\n')); + for (const file of [bashRc, zshRc]) { + assert.match(fs.readFileSync(file, 'utf8'), /CCXRAY_EXPORT_GCS_BUCKET='fixture-bucket-01'/, file); + } + assert.equal(exportSync.readEnrollment(setupHome(fixture)).state, 'enrolled'); + }); +}); + +test('--export upgrades a record-only install to the export block', async () => { + await withFixture(async fixture => { + assert.equal(await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture, { + credentials: spyCredentials().api, + })), 0); + const log = makeLogger(); + const result = await setup.runSetup(installArgs(), depsFor(fixture, { logger: log })); + assert.equal(result, 0, log.lines.join('\n')); + assert.match(fs.readFileSync(rcFile(fixture), 'utf8'), /CCXRAY_EXPORT_GCS_BUCKET='fixture-bucket-01'/); + assert.equal(exportSync.readEnrollment(setupHome(fixture)).state, 'enrolled'); + assert.ok(log.lines.some(line => line.includes('basenames export unmasked'))); + }); +}); + test('a non-TTY install refuses before taking the lock or writing files', async () => { await withFixture(async fixture => { let locks = 0; @@ -1064,7 +1266,7 @@ test('a non-TTY install refuses before taking the lock or writing files', async }; const log = makeLogger(); const result = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', ], depsFor(fixture, { logger: log, isTTY: false, @@ -1439,7 +1641,7 @@ test('PowerShell consent names a missing profile parent directory', async () => const prompts = []; const log = makeLogger(); const result = await setup.runSetup([ - '--shell', 'pwsh', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', + '--shell', 'pwsh', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', ], depsFor(fixture, { logger: log, isTTY: true, @@ -1503,7 +1705,7 @@ test('consent-time symlink retarget and file edit abort phase A without any setu fs.writeFileSync(newTarget, 'export USER_RC=new\n'); fs.symlinkSync(path.basename(oldTarget), logical); const log = makeLogger(); - const result = await setup.runSetup(['--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test'], depsFor(fixture, { + const result = await setup.runSetup(['--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test'], depsFor(fixture, { logger: log, isTTY: true, prompt: async question => { @@ -1525,7 +1727,7 @@ test('consent-time symlink retarget and file edit abort phase A without any setu const logical = rcFile(fixture); fs.writeFileSync(logical, 'export USER_RC=before\n'); const log = makeLogger(); - const result = await setup.runSetup(['--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test'], depsFor(fixture, { + const result = await setup.runSetup(['--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test'], depsFor(fixture, { logger: log, isTTY: true, prompt: async question => { @@ -1553,7 +1755,7 @@ test('phase A rejects same-size byte edits even when the original mtime is resto const stat = fs.statSync(logical); const log = makeLogger(); const result = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', ], depsFor(fixture, { logger: log, isTTY: true, @@ -1674,7 +1876,7 @@ test('install/remove phase-A interleavings are resolved by the operation that va let nestedRemoveCode = null; const outerLog = makeLogger(); const outer = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', ], depsFor(fixture, { logger: outerLog, isTTY: true, @@ -1703,7 +1905,7 @@ test('install/remove phase-A interleavings are resolved by the operation that va logger: outerLog, isTTY: true, prompt: async () => { - nestedInstallCode = await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture)); + nestedInstallCode = await setup.runSetup(installArgs(), depsFor(fixture)); return 'yes'; }, })); @@ -1959,7 +2161,7 @@ test('an interrupted first enrollment can seed its cursor from the original enro failCursor = false; const secondDay = Date.parse('2026-10-15T00:01:00.000Z'); - const second = await setup.runSetup(['--shell', 'bash', '--yes'], depsFor(fixture, { + const second = await setup.runSetup(installArgs(), depsFor(fixture, { now: () => secondDay, fs: injectedFs, })); @@ -2411,7 +2613,7 @@ test('install, replacement, and removal preserve a payload BOM in UTF-8 and UTF- assert.ok(current.subarray(0, original.length).equals(original), `${item.name} install changed foreign bytes`); const replacement = await setup.runSetup([ - '--shell', 'bash', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', + '--shell', 'bash', '--export', '--bucket', 'fixture-bucket-02', '--domains', 'example.test', '--yes', ], depsFor(fixture)); assert.equal(replacement, 0, item.name); current = fs.readFileSync(rcPath); @@ -2730,8 +2932,10 @@ test('check fails for malformed or unreadable explicitly selected key-file crede describeCredentials() { return 'synthetic key file failure'; }, credentialText() { return 'synthetic key file failure'; }, }; + // #691: the credential is only checked when export is configured; the + // explicitly selected key file is that configuration here. const result = await setup.runSetup(['--check', '--shell', 'bash', '--no-probe'], depsFor(fixture, { - logger: log, credentials, + logger: log, credentials, env: { CCXRAY_EXPORT_GCS_KEY_FILE: path.join(fixture.paths.root, 'synthetic-key.json') }, })); assert.equal(result, 1, log.lines.join('\n')); assert.ok(log.lines.some(line => line.includes('adc: failed') && line.includes('synthetic key file failure'))); @@ -2916,7 +3120,7 @@ test('POSIX wrappers cover every bypass, missing-ccxray fallback, and one-launch const executable = fixtureExecutable(fixture, shell); if (!executable) continue; const installed = await setup.runSetup([ - '--shell', shell, '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', + '--shell', shell, '--export', '--bucket', 'fixture-bucket-01', '--domains', 'example.test', '--yes', ], depsFor(fixture)); assert.equal(installed, 0, `${shell} install`); for (const command of bypasses) {