mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: pass --name to local claude spawns so workers carry their session names as peer names
Version-gated fail-closed at 2.1.224 (the cross-session-messaging release, flag presence verified against that binary): an unknown or older CLI yields a spawn command byte-identical to before, because claude aborts startup on an unknown option and that would kill every session spawn. The value is allowlist-sanitized ahead of the double-quoted interpolation, and only the local command carries the flag; docker/remote builders never see it since their CLI is not the probed binary. Verified E2E on an isolated instance: cmdline shows --name, ListAgents lists the session name, replies arrive tagged from-name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,4 +2,6 @@
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
Codeman agent skill: cross-session messaging integration. The skill now teaches agents to drive claude workers over Claude Code's cross-session messaging (`ListAgents`/`SendMessage`, CLI v2.1.224+) where available: map `ListAgents` rows to Codeman sessions via the `tmux codeman-<id8>` column, deliver multi-line exactly-once task messages (including mid-turn steering of a busy worker), collect results as latched replies instead of polling, and fall back to the HTTP recipes whenever the feature is absent (version, feature flag, telemetry-disabling env vars, Docker/remote cases, non-claude modes). Adds `reference/messaging.md` (ships automatically, the skill installer enumerates `reference/*.md`), fan-out Flow 5 in `reference/recipes.md`, new troubleshooting rows in `reference/endpoints.md`, and safety rules for the shared peer namespace (message only workers you created, no permission laundering in either direction). All mechanics verified live against claude-cli 2.1.226.
|
||||
Cross-session messaging integration, two halves. **Workers now carry their Codeman session names as messaging peer names**: local claude spawns pass `--name <session name>` when the installed CLI is 2.1.224+ (the cross-session-messaging release). The gate is fail-closed, since an older claude aborts startup on an unknown option: an unknown or older version yields a spawn command byte-identical to before, the value is allowlist-sanitized before shell interpolation, and docker/remote spawns never carry the flag (their CLI is not the probed binary). Verified end to end on an isolated instance: the worker lists as its session name in `ListAgents`, and its replies arrive tagged `from-name="<session name>"`.
|
||||
|
||||
**The Codeman agent skill teaches cross-session messaging**: drive claude workers over `ListAgents`/`SendMessage` where available, map rows to Codeman sessions via the `tmux codeman-<id8>` column, deliver multi-line exactly-once task messages (including mid-turn steering), collect results as latched replies instead of polling, and fall back to the HTTP recipes whenever the feature is absent (version, feature flag, telemetry-disabling env vars, Docker/remote cases, non-claude modes). Adds `reference/messaging.md` (ships automatically, the installer enumerates `reference/*.md`), fan-out Flow 5 in `reference/recipes.md`, troubleshooting rows in `reference/endpoints.md`, and safety rules for the shared peer namespace (message only workers you created, no permission laundering in either direction). All mechanics verified live against claude-cli 2.1.226.
|
||||
|
||||
@@ -738,7 +738,22 @@ Verified live (claude-cli 2.1.226, Linux):
|
||||
expires unattended (upstream default 5 min), which on a headless worker means the
|
||||
message silently dies. The skill's backstop covers it.
|
||||
|
||||
Deliberately NOT done: passing `claude --name <sessionName>` at spawn so peers carry
|
||||
Codeman session names. The flag exists in 2.1.226, but gating it against older CLIs
|
||||
risks the worst regression class (sessions failing to spawn on an unknown flag), so
|
||||
it stays a follow-up behind a version/flag probe.
|
||||
Follow-up, landed in the same PR: local claude spawns now pass
|
||||
`--name <session name>` so peers carry Codeman session names. The gate is
|
||||
`buildNameCliArgs()` (session-cli-builder.ts), fail-closed at
|
||||
`CLAUDE_NAME_FLAG_MIN_VERSION = 2.1.224`: that is the messaging release, the flag's
|
||||
presence there was verified against the installed 2.1.224 binary, and the version
|
||||
comes from `getClaudeCliVersion()` (null on probe failure and under vitest), so an
|
||||
older or unknown CLI gets a command byte-identical to before. That matters because
|
||||
claude aborts startup on an unknown option, which would kill every session spawn.
|
||||
The value is allowlist-sanitized (Unicode letters/digits plus ` ._:-`, leading
|
||||
dashes stripped so it cannot parse as another option, 64-char cap, empty result =
|
||||
flag omitted) before the double-quoted interpolation in `buildSpawnCommand`, and
|
||||
only the LOCAL command carries it: the docker/remote builders never see it, since
|
||||
their CLI is not the binary the probe measured. E2E on an isolated instance
|
||||
(`CODEMAN_INSTANCE`): process cmdline `claude ... --name w9-msgtest`, registry
|
||||
`name: "w9-msgtest"`, `ListAgents` lists it under that name, a message round-trip
|
||||
works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
|
||||
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
|
||||
empty Codeman name, so the peer name stays derived: agents should name their
|
||||
workers. Tests: `test/name-flag-injection.test.ts`.
|
||||
|
||||
@@ -414,9 +414,11 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
||||
|
||||
1. Spawn + readiness over HTTP, unchanged (§3, Flow 1).
|
||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||
column; the row's `name [ref]` is the address. No row = messaging is off for that
|
||||
worker (it is feature-flagged even on matching CLI versions, observed live): fall
|
||||
back to the HTTP recipes without complaint.
|
||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||
the listing (a bare name errors asking for the ref). End the task with a reply
|
||||
instruction: "when done, reply to the sender of this message with one line:
|
||||
|
||||
@@ -61,6 +61,17 @@ your quick-start's `sessionId`. The peer NAME (`msgtest-worker-cf`) is assigned
|
||||
Claude Code, derived from the case directory's folder name plus a suffix Codeman does
|
||||
not control: never guess it from the case name, read it from the listing.
|
||||
|
||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||
CLI is 2.1.224+, so a worker's peer name usually IS its Codeman session name
|
||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it) and
|
||||
allowlist-sanitized (a name of only unsafe characters is dropped), and docker/remote
|
||||
spawns never carry it, which is why the `tmux` column stays the canonical join key
|
||||
rather than the name.
|
||||
|
||||
Scriptable probe + name lookup, against the registry Claude Code maintains (one JSON
|
||||
object per process in `~/.claude/sessions/<pid>.json`):
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface RespawnPaneOptions {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
mode: SessionMode;
|
||||
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
|
||||
name?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
model?: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||
import { isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,53 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
||||
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
|
||||
* that ships cross-session messaging (the feature that makes the peer name matter),
|
||||
* and the flag's presence at exactly this version was verified against the installed
|
||||
* binary (`2.1.224 --help` lists `-n, --name`). The gate MUST stay fail-closed: an
|
||||
* older or unknown CLI aborts startup on an unknown flag ("error: unknown option"),
|
||||
* which would kill every session spawn — so no version means no flag, and the
|
||||
* command line stays byte-identical to the pre-`--name` one.
|
||||
*/
|
||||
export const CLAUDE_NAME_FLAG_MIN_VERSION = '2.1.224';
|
||||
|
||||
/**
|
||||
* Reduce a Codeman session name to a string safe to pass as the Claude CLI
|
||||
* `--name` value. Allowlist, not escaping: keeps Unicode letters/digits (CJK
|
||||
* session names survive) plus ` . _ : -`, which excludes every character that is
|
||||
* special inside the double-quoted shell interpolation buildSpawnCommand uses
|
||||
* (`"`, `$`, backslash, backtick) as well as newlines. Leading dashes/punctuation
|
||||
* are stripped so the value can never be parsed as another CLI option, and the
|
||||
* result is capped at 64 chars. Returns undefined when nothing safe remains —
|
||||
* callers must then omit the flag entirely (never send `--name ""`).
|
||||
*/
|
||||
export function sanitizeCliSessionName(name?: string): string | undefined {
|
||||
if (!name) return undefined;
|
||||
const cleaned = name
|
||||
.replace(/[^\p{L}\p{N} ._:-]/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^[\s._:-]+/, '')
|
||||
.trim()
|
||||
.slice(0, 64)
|
||||
.trim();
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `--name <session name>` args pair, version-gated and fail-closed.
|
||||
* Returns [] unless the CLI version is KNOWN to support the flag (>= 2.1.224):
|
||||
* a null/undefined version (probe failed, or running under vitest where
|
||||
* getClaudeCliVersion() is hermetically null) yields [], keeping the spawn
|
||||
* command identical to a Codeman without this feature. The name itself is a
|
||||
* SOFT default, exactly like model and effort: `/rename` in-session still works.
|
||||
*/
|
||||
export function buildNameCliArgs(sessionName: string | undefined, cliVersion: string | null | undefined): string[] {
|
||||
if (!cliVersion || compareVersions(cliVersion, CLAUDE_NAME_FLAG_MIN_VERSION) < 0) return [];
|
||||
const name = sanitizeCliSessionName(sessionName);
|
||||
return name ? ['--name', name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
|
||||
*
|
||||
@@ -60,6 +108,8 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
||||
* @param model - Optional model override (e.g., 'opus', 'sonnet')
|
||||
* @param allowedTools - Optional comma-separated allowed tools list
|
||||
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
||||
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
|
||||
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildInteractiveArgs(
|
||||
@@ -67,11 +117,14 @@ export function buildInteractiveArgs(
|
||||
claudeMode: ClaudeMode,
|
||||
model?: string,
|
||||
allowedTools?: string,
|
||||
effort?: EffortLevel
|
||||
effort?: EffortLevel,
|
||||
sessionName?: string,
|
||||
cliVersion?: string | null
|
||||
): string[] {
|
||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||
if (model) args.push('--model', model);
|
||||
args.push(...buildEffortCliArgs(effort));
|
||||
args.push(...buildNameCliArgs(sessionName, cliVersion));
|
||||
return args;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -1406,6 +1406,7 @@ export class Session extends EventEmitter {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
@@ -1710,7 +1711,15 @@ export class Session extends EventEmitter {
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
||||
const args = buildInteractiveArgs(
|
||||
this.id,
|
||||
this._claudeMode,
|
||||
this._model,
|
||||
this._allowedTools,
|
||||
this._effort,
|
||||
this._name,
|
||||
getClaudeCliVersion()
|
||||
);
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
name: 'xterm-256color',
|
||||
|
||||
+35
-4
@@ -49,7 +49,7 @@ import {
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
|
||||
import {
|
||||
buildSshConnectionArgs,
|
||||
defaultRemoteCommandForMode,
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
findClaudeDir,
|
||||
getClaudeCliVersion,
|
||||
resolveOpenCodeDir,
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
@@ -752,6 +753,20 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
||||
return flag && value ? ` ${flag} '${value}'` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ` --name "<session name>"` shell fragment, or '' when it must be
|
||||
* omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI
|
||||
* aborts startup on an unknown flag, which would kill every claude spawn), and
|
||||
* the value is allowlist-sanitized there, so it contains none of the characters
|
||||
* that are special inside this double-quoted interpolation. The peer name is a
|
||||
* soft default (in-session /rename still wins), which is why this rides the
|
||||
* spawn command rather than any persisted config.
|
||||
*/
|
||||
function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string {
|
||||
const [flag, value] = buildNameCliArgs(sessionName, cliVersion);
|
||||
return flag && value ? ` ${flag} "${value}"` : '';
|
||||
}
|
||||
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
@@ -764,12 +779,25 @@ export function buildSpawnCommand(options: {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||
sessionName?: string;
|
||||
/**
|
||||
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
||||
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
|
||||
* docker/remote paths never see this builder's output, which is what keeps the
|
||||
* gate measuring the RIGHT binary — the local one.
|
||||
*/
|
||||
claudeCliVersion?: string | null;
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||
const effortFlag = buildEffortSettingsFlag(options.effort);
|
||||
const nameFlag = buildClaudeNameFlag(
|
||||
options.sessionName,
|
||||
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
|
||||
);
|
||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||
// fall back to a new session with --session-id so the pane doesn't die.
|
||||
@@ -777,11 +805,11 @@ export function buildSpawnCommand(options: {
|
||||
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
|
||||
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
|
||||
if (safeResumeId) {
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
|
||||
return `${resumeCmd} || ${fallbackCmd}`;
|
||||
}
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
|
||||
}
|
||||
if (options.mode === 'opencode') {
|
||||
return buildOpenCodeCommand(options.openCodeConfig);
|
||||
@@ -1789,6 +1817,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2016,6 +2045,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
name,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -2050,6 +2080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @fileoverview Tests for the version-gated `--name <session name>` claude spawn flag.
|
||||
*
|
||||
* The flag makes a Codeman claude worker's cross-session-messaging peer name equal
|
||||
* its Codeman session name. The gate MUST be fail-closed: a claude CLI older than
|
||||
* 2.1.224 aborts startup on an unknown option, which would kill every session spawn,
|
||||
* so an unknown/absent version must produce a command byte-identical to the
|
||||
* pre-`--name` one. Covers both spawn paths (buildInteractiveArgs for the direct
|
||||
* PTY fallback, buildSpawnCommand for the tmux pane command) plus the allowlist
|
||||
* sanitizer that keeps the double-quoted shell interpolation injection-free.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
buildNameCliArgs,
|
||||
sanitizeCliSessionName,
|
||||
CLAUDE_NAME_FLAG_MIN_VERSION,
|
||||
} from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
|
||||
describe('sanitizeCliSessionName', () => {
|
||||
it('passes ordinary Codeman session names through', () => {
|
||||
expect(sanitizeCliSessionName('w1-msgtest-worker')).toBe('w1-msgtest-worker');
|
||||
expect(sanitizeCliSessionName('w18-claudeman: pi')).toBe('w18-claudeman: pi');
|
||||
});
|
||||
|
||||
it('keeps Unicode letters (CJK session names survive)', () => {
|
||||
expect(sanitizeCliSessionName('会话-测试 w2')).toBe('会话-测试 w2');
|
||||
});
|
||||
|
||||
it('strips every character that is special inside double quotes', () => {
|
||||
const cleaned = sanitizeCliSessionName('w1"; $(rm -rf /) `boom` \\ $HOME');
|
||||
expect(cleaned).toBeDefined();
|
||||
// The double-quote interpolation in buildSpawnCommand is only safe because
|
||||
// none of these can survive: " $ ` \ and newlines.
|
||||
expect(cleaned).not.toMatch(/["$`\\\n\r]/);
|
||||
expect(cleaned).not.toMatch(/[();/]/);
|
||||
});
|
||||
|
||||
it('strips leading dashes so the value cannot parse as another CLI option', () => {
|
||||
expect(sanitizeCliSessionName('--resume')).toBe('resume');
|
||||
expect(sanitizeCliSessionName('-x')).toBe('x');
|
||||
});
|
||||
|
||||
it('collapses whitespace and caps length at 64', () => {
|
||||
expect(sanitizeCliSessionName('a b\t c')).toBe('a b c');
|
||||
const long = 'x'.repeat(200);
|
||||
expect(sanitizeCliSessionName(long)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing safe remains (flag must be omitted, never --name "")', () => {
|
||||
expect(sanitizeCliSessionName(undefined)).toBeUndefined();
|
||||
expect(sanitizeCliSessionName('')).toBeUndefined();
|
||||
expect(sanitizeCliSessionName('"$`\\')).toBeUndefined();
|
||||
expect(sanitizeCliSessionName('---')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNameCliArgs version gate', () => {
|
||||
it('emits the flag from the minimum version up', () => {
|
||||
// 2.1.224 ships cross-session messaging AND is verified (locally, --help)
|
||||
// to accept --name; the constant must never drift below it.
|
||||
expect(CLAUDE_NAME_FLAG_MIN_VERSION).toBe('2.1.224');
|
||||
expect(buildNameCliArgs('w1-a', '2.1.224')).toEqual(['--name', 'w1-a']);
|
||||
expect(buildNameCliArgs('w1-a', '2.1.226')).toEqual(['--name', 'w1-a']);
|
||||
expect(buildNameCliArgs('w1-a', '2.2.0')).toEqual(['--name', 'w1-a']);
|
||||
expect(buildNameCliArgs('w1-a', '3.0.0')).toEqual(['--name', 'w1-a']);
|
||||
});
|
||||
|
||||
it('FAILS CLOSED below the minimum and on unknown versions', () => {
|
||||
// An older CLI aborts startup on an unknown flag: [] here is what keeps
|
||||
// every spawn alive on old installs.
|
||||
expect(buildNameCliArgs('w1-a', '2.1.223')).toEqual([]);
|
||||
expect(buildNameCliArgs('w1-a', '2.0.999')).toEqual([]);
|
||||
expect(buildNameCliArgs('w1-a', '1.0.128')).toEqual([]);
|
||||
expect(buildNameCliArgs('w1-a', null)).toEqual([]);
|
||||
expect(buildNameCliArgs('w1-a', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits the flag entirely when the name sanitizes away or is absent', () => {
|
||||
expect(buildNameCliArgs(undefined, '2.1.226')).toEqual([]);
|
||||
expect(buildNameCliArgs('"$`', '2.1.226')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInteractiveArgs with a session name (direct PTY path)', () => {
|
||||
it('appends --name when the version supports it', () => {
|
||||
const args = buildInteractiveArgs(
|
||||
'sid-1',
|
||||
'dangerously-skip-permissions',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'w1-a',
|
||||
'2.1.226'
|
||||
);
|
||||
const idx = args.indexOf('--name');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
expect(args[idx + 1]).toBe('w1-a');
|
||||
});
|
||||
|
||||
it('omits --name on an old or unknown version', () => {
|
||||
expect(
|
||||
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', '2.1.223')
|
||||
).not.toContain('--name');
|
||||
expect(
|
||||
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', null)
|
||||
).not.toContain('--name');
|
||||
// Version parameter omitted entirely = same fail-closed omission
|
||||
expect(
|
||||
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a')
|
||||
).not.toContain('--name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand with a session name (tmux path)', () => {
|
||||
const base = {
|
||||
mode: 'claude' as const,
|
||||
sessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
|
||||
claudeMode: 'dangerously-skip-permissions' as const,
|
||||
};
|
||||
|
||||
it('appends a quoted --name when the injected version supports it', () => {
|
||||
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-msgtest-worker', claudeCliVersion: '2.1.226' });
|
||||
expect(cmd).toContain(' --name "w1-msgtest-worker"');
|
||||
});
|
||||
|
||||
it('stays byte-identical to the flagless command on an old version', () => {
|
||||
const withOld = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: '2.1.223' });
|
||||
const without = buildSpawnCommand({ ...base, claudeCliVersion: '2.1.223' });
|
||||
expect(withOld).toBe(without);
|
||||
expect(withOld).not.toContain('--name');
|
||||
});
|
||||
|
||||
it('stays byte-identical when the version probe failed (null)', () => {
|
||||
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: null });
|
||||
expect(cmd).toBe(buildSpawnCommand({ ...base, claudeCliVersion: null }));
|
||||
});
|
||||
|
||||
it('defaults fail-closed when no version is injected (vitest probe is hermetically null)', () => {
|
||||
// In production the omitted field resolves through getClaudeCliVersion();
|
||||
// under vitest that is null by design, which doubles as the fail-closed pin.
|
||||
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a' });
|
||||
expect(cmd).not.toContain('--name');
|
||||
});
|
||||
|
||||
it('carries the flag in BOTH branches of the resume fallback chain', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
...base,
|
||||
sessionName: 'w1-a',
|
||||
claudeCliVersion: '2.1.226',
|
||||
resumeSessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
|
||||
});
|
||||
const occurrences = cmd.split(' --name "w1-a"').length - 1;
|
||||
expect(cmd).toContain(' || ');
|
||||
expect(occurrences).toBe(2);
|
||||
});
|
||||
|
||||
it('sanitizes a hostile name before interpolation', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
...base,
|
||||
sessionName: 'w1"; rm -rf /; echo "',
|
||||
claudeCliVersion: '2.1.226',
|
||||
});
|
||||
const m = cmd.match(/ --name "([^"]*)"/);
|
||||
expect(m).not.toBeNull();
|
||||
// Whatever remains inside the quotes must be inert: no quote/dollar/backtick/
|
||||
// backslash can survive the allowlist, so the shell sees one literal argv.
|
||||
expect(m![1]).not.toMatch(/["$`\\;/]/);
|
||||
});
|
||||
|
||||
it('never adds --name to non-claude modes', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: base.sessionId, sessionName: 'w1-a' });
|
||||
expect(cmd).not.toContain('--name');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user