fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW
This commit is contained in:
timkjr
2026-09-07 19:15:54 -05:00
co-authored by Claude Sonnet 5
parent a164c07f92
commit d4aa3c8cca
9 changed files with 379 additions and 44 deletions
+110 -1
View File
@@ -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<string> {
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<string | undefined> {
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 ───────────────────────────────────────────────────
/**
+9
View File
@@ -90,6 +90,13 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** 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<string, string>;
/** 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 */
+25 -2
View File
@@ -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<string, unknown> =
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
+10
View File
@@ -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<string, string>;
/** 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,
+22
View File
@@ -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;
+13 -22
View File
@@ -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,
+15 -18
View File
@@ -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<string>();
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) {
+83 -1
View File
@@ -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();
});
});
+92
View File
@@ -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 <arg>` 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');
});
});