From d4aa3c8cca7bc4fa7be0140c01067d61305b7f1a Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 30 Aug 2026 17:31:25 -0500 Subject: [PATCH] fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codeman's plan-usage chip wrote a statusLine.command into the case's .claude/settings.local.json to receive Claude Code's rate_limits blob. That file-based statusLine took precedence over the user's own global/project statusline for ANY `claude` run in that directory, including entirely outside Codeman, with no disclosure in the App Settings UI (labeled only as a header-display toggle) and no way to remove it once written (the removal code path was unreachable dead code — nothing ever called it with false). Replace the disk write with an EPHEMERAL `claude --settings '{"statusLine":{...}}'` CLI flag, resolved fresh at spawn time (resolveStatusLineCliCommand in hooks-config.ts) and merged with effort/ultracode into one --settings object (buildClaudeSettingsFlag in tmux-manager.ts, since Claude Code accepts only one --settings flag). Never touches disk, so a plain `claude` run outside Codeman is untouched. Self-healing: any legacy disk-written exporter from an older build is stripped the first time a session starts in that workspace again. Still respects a user's own hand-authored statusLine (skips the flag entirely rather than overriding it). Mid-fix bug found and fixed: the exporter's command legitimately depends on $CODEMAN_SESSION_ID/$CODEMAN_API_URL/$CODEMAN_HOOK_SECRET_FILE and an internal $INPUT, all meant to be expanded only when Claude Code itself executes the statusline, using the pane's tmux-setenv'd environment. Passing that text through --settings routed it through execSync's own implicit /bin/sh -c first (tmux respawn-pane's `bash -c "..."` wrapper) — POSIX double quotes don't suppress $ expansion, so those vars got expanded prematurely against the server's own environment (unset there), producing malformed JSON that printed as literal error text in the statusline. Fixed by writing the exporter as a real, shared script file (ensureStatusLineExporterScript, marker-versioned so stale copies self-heal) and passing only its bare path via --settings — nothing for any intermediate shell to mangle. Verified against a real Claude CLI on an isolated tmux socket, and via direct execSync reproduction of the exact nested wrapping createSession/respawnPane use. A hard "never inject, even ephemerally" kill-switch was added and then removed in the same pass: with the disk-leak fixed, disabling injection only cost the plan-usage telemetry the feature exists to provide, for no remaining benefit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW --- src/hooks-config.ts | 111 ++++++++++++++++++++++++++++- src/mux-interface.ts | 9 +++ src/session-cli-registry-bridge.ts | 27 ++++++- src/session.ts | 10 +++ src/tmux-manager.ts | 22 ++++++ src/web/routes/session-routes.ts | 35 ++++----- src/web/routes/system-routes.ts | 33 ++++----- test/hooks-config.test.ts | 84 +++++++++++++++++++++- test/statusline-cli-flag.test.ts | 92 ++++++++++++++++++++++++ 9 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 test/statusline-cli-flag.test.ts diff --git a/src/hooks-config.ts b/src/hooks-config.ts index d8a8a38b..d711f5ea 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -31,7 +31,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -906,6 +906,115 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean): }); } +/** + * Version-agnostic marker embedded as a comment in the generated exporter + * SCRIPT (see ensureStatusLineExporterScript) — bump the numeric suffix + * whenever the script content changes so `ensureStatusLineExporterScript`'s + * content comparison rewrites stale copies on next use. + */ +const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V1'; + +function statusLineExporterScriptContent(): string { + // Byte-identical logic to generateStatusLineCommand(), just as a real file + // instead of an inline string — see ensureStatusLineExporterScript for why + // that distinction matters (it is NOT cosmetic). + return ( + `#!/bin/sh\n` + + `# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` + + `INPUT=$(cat 2>/dev/null || echo '{}')\n` + + `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + + `curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + + `-H 'Content-Type: application/json' ` + + `-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + + `--data @- 2>/dev/null || echo codeman\n` + ); +} + +/** + * Write (or refresh) the SHARED, single exporter script every claude session + * points its ephemeral --settings statusLine flag at, and return its absolute + * path. Idempotent: only rewrites when the marker-versioned content differs. + * + * This is the fix for a real bug found live 2026-08-31: the exporter's + * command string legitimately depends on `$CODEMAN_SESSION_ID`, + * `$CODEMAN_API_URL`, `$CODEMAN_HOOK_SECRET_FILE`, and its own internal + * `$INPUT` — all meant to be expanded ONLY when Claude Code itself finally + * executes the statusLine command, using the PANE's tmux-setenv'd + * environment. Passing that command as literal TEXT through + * `--settings '...'` routes it through this server's OWN spawn-time shell + * layers first (tmux respawn-pane's `bash -c "..."`, itself invoked via + * execSync's implicit `/bin/sh -c`) — and POSIX double quotes do NOT + * suppress `$` expansion, so those vars got expanded there and then, against + * the SERVER process's environment (where they are unset), producing a + * mangled curl call that posted malformed JSON and printed the server's raw + * error response as the statusline text itself. A bare file PATH has no `$`, + * quotes, or pipes for any of those intermediate shells to mangle — the + * script's own content (containing the real `$VAR`s) is never touched by a + * shell until Claude Code executes the file itself, at which point the + * pane's real environment is in scope. This mirrors the existing #208 fix in + * tmux-manager.ts (never embed a literal `$SHELL` meant for later + * expansion — resolve it, or in this case reference a file, instead). + */ +export async function ensureStatusLineExporterScript(): Promise { + const scriptPath = dataPath('statusline-exporter.sh'); + const desired = statusLineExporterScriptContent(); + let current: string | null = null; + try { + current = await readFile(scriptPath, 'utf-8'); + } catch { + // Doesn't exist yet. + } + if (current !== desired) { + await writeFile(scriptPath, desired); + await chmod(scriptPath, 0o755); + } + return scriptPath; +} + +/** + * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` + * CLI flag for this one process (see buildClaudeSettingsFlag in + * tmux-manager.ts) — never written to disk. This supersedes the old + * applyStatusLineConfig(path, true) disk-write: a file-based statusLine + * leaked into any plain `claude` run in that directory outside Codeman + * entirely (it took precedence over the user's own global/project + * statusline with no disclosure and no way to remove it — found live + * 2026-08-31). + * + * Also self-heals: if an OLDER Codeman build already wrote its marked + * exporter into this workspace's settings.local.json, it is stripped here + * (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every + * workspace migrates off the disk-based mechanism the first time a session + * starts there again — no manual cleanup required. + * + * Returns undefined when telemetry wasn't requested, or when the workspace + * already has its OWN hand-configured statusLine (never override a real one). + */ +export async function resolveStatusLineCliCommand( + casePath: string, + telemetryRequested: boolean +): Promise { + const settingsPath = join(casePath, '.claude', 'settings.local.json'); + let userHasOwnStatusLine = false; + if (existsSync(settingsPath)) { + try { + const existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + const current = existing.statusLine as { command?: unknown } | undefined; + if (current && typeof current.command === 'string') { + if (current.command.includes(STATUSLINE_MARKER)) { + await applyStatusLineConfig(casePath, false); // strip legacy disk-written exporter + } else { + userHasOwnStatusLine = true; + } + } + } catch { + // Malformed — leave it alone, same guard applyStatusLineConfig itself uses. + } + } + if (!telemetryRequested || userHasOwnStatusLine) return undefined; + return ensureStatusLineExporterScript(); +} + // ─── Agent skill injection ─────────────────────────────────────────────────── /** diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f4e3dd24..0e2009dd 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -90,6 +90,13 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; + /** + * Claude-only: request the plan-usage statusLine exporter for this session, + * injected as an EPHEMERAL `--settings` CLI flag (never written to disk — see + * generateStatusLineCommand/resolveStatusLineCliCommand). Skipped when the + * workspace already has its own hand-configured statusLine. + */ + statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -125,6 +132,8 @@ export interface RespawnPaneOptions { envOverrides?: Record; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; + /** Preserved across respawns — see CreateSessionOptions.statusLineTelemetry. */ + statusLineTelemetry?: boolean; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index d49e2447..e11e1449 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -56,6 +56,14 @@ export interface SpawnBridgeOptions { effort?: EffortLevel; sessionName?: string; claudeCliVersion?: string | null; + /** + * Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the + * exporter. Claude only. Rides the SAME `--settings` JSON object as `effortSettingsJson` + * (see buildSpawnCommandFromRegistry): Claude Code accepts only one `--settings` flag + * per invocation, so the two must be merged before reaching the argv engine rather than + * rendered as two independent params. + */ + statusLineCommand?: string; } /** @@ -186,8 +194,23 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri // than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the // settings-JSON shape single-sourced in session-cli-builder.ts. const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); - if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; - else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + if (effortFlag === '--effort') { + engineValues.effortLevel = effortValue; + } + + // Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in + // hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude + // Code accepts only one `--settings` flag per invocation — rendering them as two independent + // params would let the second one silently win. Claude-only in practice (statusLineCommand + // is resolved claude-mode-only upstream), but this merge is mode-agnostic. + if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) { + const settingsObj: Record = + effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}; + if (options.statusLineCommand) { + settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; + } + engineValues.effortSettingsJson = JSON.stringify(settingsObj); + } // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means diff --git a/src/session.ts b/src/session.ts index 59caaf55..40abec0a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -577,6 +577,11 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; + // Claude-only: request the plan-usage statusLine exporter for this session, + // injected as an ephemeral `--settings` CLI flag at spawn (never written to + // disk). Preserved across respawns like _effort above. + private _statusLineTelemetry: boolean | undefined; + // tmux history-limit (scrollback lines) allocated when this session's pane is created. private readonly _tmuxHistoryLimit: number; @@ -673,6 +678,8 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Claude-only: request the plan-usage statusLine exporter (ephemeral --settings flag, never disk-written) */ + statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ @@ -826,6 +833,7 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } + this._statusLineTelemetry = config.statusLineTelemetry; this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; @@ -1746,6 +1754,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2061,6 +2070,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9fdd2bda..61eb1c4f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,6 +67,7 @@ import { legacyConfigForMode, } from './session-cli-registry-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; +import { resolveStatusLineCliCommand } from './hooks-config.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -661,6 +662,8 @@ export function buildSpawnCommand(options: { ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; + /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ + statusLineCommand?: string; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ sessionName?: string; /** @@ -1729,6 +1732,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + statusLineTelemetry, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -1787,6 +1791,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // Claude-only, local spawns only (remote/docker have their own separate + // command builders — out of scope here). Also self-heals: strips any + // legacy disk-written exporter from an older Codeman build the first + // time a session starts in that workspace again. + const statusLineCommand = + mode === 'claude' && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -1803,6 +1816,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); @@ -2027,6 +2041,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + statusLineTelemetry, remote, docker, name, @@ -2042,6 +2057,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // See createSession()'s identical resolution for rationale. + const statusLineCommand = + mode === 'claude' && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -2058,6 +2079,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); const config = niceConfig || DEFAULT_NICE_CONFIG; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9ea80060..2f6649f3 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -94,7 +94,6 @@ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, - applyStatusLineConfig, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, @@ -948,27 +947,18 @@ export function registerSessionRoutes( await updateCaseModel(workingDir, body.modelOverride || null); } - // Plan-usage statusLine exporter (App Settings → Display → "Plan Usage - // Limits"). Claude-only; runs for ANY working dir (linked cases / real repos, - // where most sessions live), mirroring updateCaseModel above. - // - // ADD-ONLY: we never remove on create. Sessions in a repo share one - // settings.local.json, so a single create-with-false (e.g. a client whose - // synced setting hadn't loaded yet) must NOT yank the statusLine out from - // under other live sessions in that repo — that breaks their footer + the - // chip's data feed for everyone. The exporter is benign when the chip is off - // (the footer just shows session status). isOurs-guarded so a user's own - // statusLine is never touched. - // - // Same guard as the hooks call below (499d355): never for a remote attach - // (workingDir is a user@host:session pseudo-path — the mkdir inside - // applyStatusLineConfig would create it as a junk local dir), and only when - // the caller named a workingDir — the process-cwd fallback is $HOME under - // installer-created services, and a statusLine materializing in - // ~/.claude/settings.local.json was never asked for. - if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) { - await applyStatusLineConfig(workingDir, true); - } + // Plan-usage telemetry request (App Settings → header chip). NO LONGER a + // disk write here — a settings.local.json statusLine took precedence over + // the user's own global/project statusLine for ANY `claude` run in that + // directory, including entirely outside Codeman, with no disclosure and no + // way to undo it (real bug, found 2026-08-31). The request now flows + // through as an ordinary session field (statusLineTelemetryRequested below) + // and Session/TmuxManager resolve it into an EPHEMERAL `claude --settings` + // CLI flag at actual spawn time (resolveStatusLineCliCommand in + // hooks-config.ts) — never written to disk, so a plain `claude` run outside + // Codeman is untouched. That resolution also self-heals: it strips any + // legacy disk-written exporter an older Codeman build left behind. + const statusLineTelemetryRequested = body.statusLineTelemetry === true; // Hooks for the workspace this session runs in (install vs refresh-only is the // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote @@ -1102,6 +1092,7 @@ export function registerSessionRoutes( resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, + statusLineTelemetry: statusLineTelemetryRequested, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, remote, owner, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index f409e911..ab4f2194 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -5,7 +5,6 @@ */ import { FastifyInstance } from 'fastify'; -import { getCli } from '../../config/cli-registry/registry.js'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; @@ -33,7 +32,6 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { workflowRunWatcher } from '../../workflow-run-watcher.js'; -import { applyStatusLineConfig } from '../../hooks-config.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { buildAwayDigest, @@ -994,7 +992,9 @@ export function registerSystemRoutes( } // statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored // settings) — strip them before persisting so settings.json stays clean. - const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // statusLineTelemetry is an action field only (see below); it must never + // land in settingsToStore. + const { statusLineTelemetry: _statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings; const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); @@ -1034,21 +1034,18 @@ export function registerSystemRoutes( }); // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). - // Telemetry COLLECTION is server-side and enable-sticky — when a client turns - // the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter - // into every ACTIVE Claude session's working dir so the live % starts flowing - // immediately (no new session needed). We deliberately never auto-REMOVE here: - // the exporter is benign/print-through and a per-repo settings.local.json is - // shared by sibling sessions, so one device's "off" must not yank the exporter - // another device's chip depends on. Each dir handled once. - if (statusLineTelemetry === true) { - const dirs = new Set(); - for (const session of ctx.sessions.values()) { - if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir) - dirs.add(session.workingDir); - } - await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {}))); - } + // Telemetry COLLECTION was previously server-side and enable-sticky here — + // toggling the chip ON re-injected a statusLine.command into every ACTIVE + // Claude session's settings.local.json so live % started flowing without a + // new session. That disk write is exactly the bug fixed 2026-08-31 (it took + // precedence over the user's own statusline for ANY `claude` run in that + // directory, including outside Codeman, with no way to undo it). Telemetry + // is now requested per-session at CREATE/RESPAWN time only (statusLineTelemetry + // threaded through cron/ralph-loop/quick-start/interactive-create, see those + // route handlers), resolved into an ephemeral `--settings` CLI flag — fixed at + // spawn, so there is nothing to (re)inject into an ALREADY-RUNNING session + // here, unlike the old disk mechanism. The action field above is received and + // discarded; flipping the chip ON only affects sessions created from now on. // Handle tunnel toggle dynamically if ('tunnelEnabled' in settings) { diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 774774b3..299b37e6 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -6,7 +6,17 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; -import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { + closeSync, + existsSync, + openSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, + symlinkSync, + statSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; @@ -15,8 +25,10 @@ import { ensureCodemanHooks, generateBackgroundWakeScript, generateHooksConfig, + generateStatusLineCommand, generateSubagentStopGuardScript, refreshStaleCodemanHooks, + resolveStatusLineCliCommand, settingsWriteBlocker, stripCaseEnvKeys, updateCaseEnvVars, @@ -1305,3 +1317,73 @@ describe('Hook Config Generation - Extended', () => { expect(stopHooks[0].hooks[0].command).toContain('stop'); }); }); + +describe('resolveStatusLineCliCommand', () => { + const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns undefined when telemetry was not requested', async () => { + expect(await resolveStatusLineCliCommand(testDir, false)).toBeUndefined(); + }); + + it('returns a bare exporter SCRIPT PATH (never the inline command) when requested', async () => { + // A bare path has no `$`, quotes, or pipes for any intermediate shell + // layer to mangle — see ensureStatusLineExporterScript's doc comment for + // the real bug this guards against. + const cmd = await resolveStatusLineCliCommand(testDir, true); + expect(cmd).toBeDefined(); + expect(cmd).not.toContain('$'); + expect(cmd).not.toContain("'"); + expect(cmd).toMatch(/^\/.*statusline-exporter\.sh$/); + expect(existsSync(cmd!)).toBe(true); + const stat = statSync(cmd!); + expect(stat.mode & 0o111).not.toBe(0); // executable + expect(readFileSync(cmd!, 'utf-8')).toContain('CODEMAN_STATUSLINE_EXPORTER_V'); + }); + + it('never overrides a real, hand-authored statusLine', async () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo my-own-prompt' } }, null, 2) + ); + + expect(await resolveStatusLineCliCommand(testDir, true)).toBeUndefined(); + + // The user's own config is untouched — this is a read-only decision, not a write. + const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); + expect(parsed.statusLine.command).toBe('echo my-own-prompt'); + }); + + it('self-heals: strips a legacy disk-written exporter from an older Codeman build', async () => { + // Simulate a workspace touched by the pre-fix applyStatusLineConfig(dir, true). + await applyStatusLineConfig(testDir, true); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeDefined(); + + const cmd = await resolveStatusLineCliCommand(testDir, true); + + // Cleaned off disk... + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined(); + // ...and telemetry still flows, via the ephemeral CLI flag instead. + expect(cmd).toMatch(/statusline-exporter\.sh$/); + }); + + it('does not resurrect the legacy exporter when telemetry is off during cleanup', async () => { + await applyStatusLineConfig(testDir, true); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + + const cmd = await resolveStatusLineCliCommand(testDir, false); + + expect(cmd).toBeUndefined(); + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined(); + }); +}); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts new file mode 100644 index 00000000..d6721d2a --- /dev/null +++ b/test/statusline-cli-flag.test.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Tests for the plan-usage statusLine exporter riding an EPHEMERAL + * `claude --settings` CLI flag (buildSpawnCommand's statusLineCommand option), + * which superseded writing it into `.claude/settings.local.json` — see + * resolveStatusLineCliCommand in hooks-config.ts and its own tests. Verified + * live against a real Claude CLI (isolated tmux socket, 2026-08-31) that + * `--settings` accepts this exact shape and takes precedence over a file-based + * statusLine. + * + * Extracting and re-parsing the `--settings` argument goes through a REAL + * shell (bash -c) rather than a hand-rolled unescaper: the exporter command + * itself embeds both single and double quotes, so trusting anything but the + * shell's own quoting rules to reverse shellescape() would just be testing + * this file's guess at the algorithm, not the actual behavior a spawned pane + * sees. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; + +const EXPORTER_CMD = 'curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || echo codeman'; + +/** Extract the `--settings ` fragment from a built command and have a + * real shell resolve its quoting, printing the arg back out verbatim. */ +function extractSettingsJson(cmd: string): unknown { + const idx = cmd.indexOf('--settings '); + expect(idx).toBeGreaterThan(-1); + const fragment = cmd.slice(idx); + const out = execFileSync('bash', ['-c', `set -- ${fragment}; printf '%s' "$2"`]).toString(); + return JSON.parse(out); +} + +describe('buildSpawnCommand statusLineCommand (claude mode)', () => { + it('omits --settings entirely when no statusLineCommand and no effort are given', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' }); + expect(cmd).not.toContain('--settings'); + }); + + it('embeds the exporter command under a statusLine settings key', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD }); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('merges statusLine and ultracode into the SAME --settings object', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'ultracode', + statusLineCommand: EXPORTER_CMD, + }); + // Only one --settings flag total — never two (Claude Code accepts just one). + expect(cmd.match(/--settings/g)).toHaveLength(1); + expect(extractSettingsJson(cmd)).toEqual({ + ultracode: true, + statusLine: { type: 'command', command: EXPORTER_CMD }, + }); + }); + + it('keeps a regular --effort flag separate from --settings when both are present', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'high', + statusLineCommand: EXPORTER_CMD, + }); + expect(cmd).toContain('--effort'); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('shell-escapes an exporter command containing single AND double quotes without breaking the flag', () => { + // The real exporter (generateStatusLineCommand) embeds both — a naive + // `'${value}'` wrap would be broken out of by the single quotes. + const tricky = `echo '{}'; printf '{"a":1}' | curl -sk`; + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: tricky }); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } }); + }); + + it('round-trips the REAL exporter command unmodified (generateStatusLineCommand)', async () => { + const { generateStatusLineCommand } = await import('../src/hooks-config.js'); + const real = generateStatusLineCommand(); + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: real }); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: real } }); + }); + + it('never adds --settings for non-claude modes even if statusLineCommand is somehow set', () => { + const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD } as never); + expect(cmd).not.toContain('--settings'); + }); +});