mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
a164c07f92
commit
d4aa3c8cca
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user