mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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
+110
-1
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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