Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .well-known/dev.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"schema_version": 1,
"channel": "dev",
"version": "4.260522.20",
"released_at": "2026-05-22T20:41:26Z",
"tarball_base": "https://github.com/automagik-dev/genie/releases/download/v4.260522.20",
"version": "4.260525.2",
"released_at": "2026-05-25T12:19:49Z",
"tarball_base": "https://github.com/automagik-dev/genie/releases/download/v4.260525.2",
"platforms": ["linux-x64-glibc", "linux-x64-musl", "linux-arm64", "darwin-arm64"]
}
162 changes: 162 additions & 0 deletions scripts/tests/omni-spawn-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env bun
/**
* Smoke test for issue #2486 — Omni-Bridge spawn command corruption.
*
* Validates that the NEW script-based path (writeTmuxLaunchScript + source)
* works reliably with long commands containing backticks, emojis, parentheses,
* and nested quotes.
*
* Run with:
* bun run scripts/tests/omni-spawn-smoke.ts
*/

import { execSync } from 'node:child_process';
import { readFileSync, unlinkSync } from 'node:fs';
import { writeTmuxLaunchScript } from '../../src/lib/tmux-launch-script.js';

const MARKER = 'OMNI_SPAWN_SUCCESS_2486';
const RESULT_FILE = `/tmp/${MARKER}`;

// Build a deliberately nasty payload with all the problematic chars.
// We wrap everything in a single 'sh -c' so writeTmuxLaunchScript's
// leading 'exec' works correctly (the real Genie launches 'claude',
// a long-running process; here we use 'sleep' to keep the pane alive
// long enough for capture).
const nastyPayload = '👍 `backticks` (instance: alpha) (ALWAYS your last action)';
const innerCommand = [
`echo '${MARKER}'`,
`export TEST_VAR='${nastyPayload}'`,
`export JSON='{"emoji":"👍","nested":"(instance: alpha)"}'`,
`export LONG='${'A'.repeat(1800)}'`,
`touch ${RESULT_FILE}`,
'sleep 1',
].join(' && ');

// The full command that Genie would pass to writeTmuxLaunchScript
const nastyCommand = `sh -c '${innerCommand.replace(/'/g, "'\\''")}'`;

function killServer(socket: string) {
try {
execSync(`tmux -L ${socket} kill-server 2>/dev/null`, { stdio: 'ignore' });
} catch {
// ignore
}
}

function cleanup(socket: string, scriptPath?: string) {
killServer(socket);
try {
unlinkSync(RESULT_FILE);
} catch {
// ignore
}
if (scriptPath) {
try {
unlinkSync(scriptPath);
} catch {
// ignore
}
}
}

function createPane(socket: string): string {
execSync(`tmux -L ${socket} new-session -d -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`, { stdio: 'ignore' });
execSync('sleep 0.3');
const paneId = execSync(`tmux -L ${socket} list-panes -F '#{pane_id}'`, { encoding: 'utf-8' }).trim();
return paneId;
}

function capturePane(socket: string, paneId: string): string {
try {
return execSync(`tmux -L ${socket} capture-pane -p -t '${paneId}' -S -10`, { encoding: 'utf-8' });
} catch {
return '';
}
}

function waitForResult(socket: string, paneId: string, timeoutMs = 5000): boolean {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const content = capturePane(socket, paneId);
if (content.includes(MARKER)) return true;
if (content.includes('parse error')) return false;
execSync('sleep 0.2');
}
return false;
}

console.log('=== Omni-Bridge Spawn Smoke Test (#2486) ===\n');

// ---------------------------------------------------------------------------
// TEST 1: OLD inline path (replicates executeTmux -> send-keys without -l)
// ---------------------------------------------------------------------------
const socket1 = 'genie-smoke-old';
console.log('TEST 1: Inline send-keys (old path)');
cleanup(socket1);
const pane1 = createPane(socket1);

// Replicate the OLD Genie path exactly:
// executeTmux(`send-keys -t '${paneId}' ${shellQuote(cmd)} Enter`)
const quotedOld = `'${nastyCommand.replace(/'/g, "'\\''")}'`;
try {
execSync(`tmux -L ${socket1} send-keys -t '${pane1}' ${quotedOld} Enter`);
} catch (e) {
console.log(` tmux send-keys itself failed: ${e instanceof Error ? e.message : e}`);
}

const oldOk = waitForResult(socket1, pane1);
const oldContent = capturePane(socket1, pane1);
const oldHasParseError = oldContent.includes('parse error');
console.log(` Parse error visible: ${oldHasParseError}`);
console.log(` Marker detected: ${oldOk}`);
console.log(` Result: ${oldOk ? 'PASS' : 'FAIL / FLAKY'}\n`);

killServer(socket1);

// ---------------------------------------------------------------------------
// TEST 2: NEW script-based path
// ---------------------------------------------------------------------------
const socket2 = 'genie-smoke-new';
console.log('TEST 2: Script-based send-keys (writeTmuxLaunchScript + source)');
cleanup(socket2);
const pane2 = createPane(socket2);

const scriptPath = writeTmuxLaunchScript('smoke-2486', nastyCommand);
// Replicate the NEW Genie path:
// executeTmux(`send-keys -t '${paneId}' "source ${scriptPath}" Enter`)
execSync(`tmux -L ${socket2} send-keys -t '${pane2}' "source ${scriptPath}" Enter`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the test is run on a machine where the home directory path contains spaces, the unquoted scriptPath will cause the source command to fail in the tmux pane. Wrapping it in single quotes ensures the path is handled correctly.

Suggested change
execSync(`tmux -L ${socket2} send-keys -t '${pane2}' "source ${scriptPath}" Enter`);
execSync("tmux -L " + shellQuote(socket2) + " send-keys -t '" + shellQuote(pane2) + "' \"source '" + shellQuote(scriptPath) + "'\" Enter");


const newOk = waitForResult(socket2, pane2);
const newContent = capturePane(socket2, pane2);
const newHasParseError = newContent.includes('parse error');
let markerOnDisk = false;
try {
markerOnDisk = readFileSync(RESULT_FILE).toString().trim() === '';
} catch {
markerOnDisk = false;
}
console.log(` Parse error visible: ${newHasParseError}`);
console.log(` Marker in pane: ${newContent.includes(MARKER)}`);
console.log(` Marker file created: ${markerOnDisk}`);
console.log(` Result: ${newOk ? 'PASS' : 'FAIL'}\n`);

cleanup(socket2, scriptPath);

// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log('=== Summary ===');
if (!oldOk && newOk) {
console.log('✅ Fix validated: inline path fails, script path is stable.');
process.exit(0);
} else if (oldOk && newOk) {
console.log('⚠️ Both paths passed on this machine (tmux/zsh may be tolerant for this payload).');
console.log(' The script path is still the safer architectural choice for 1968+ char payloads.');
process.exit(0);
} else if (!newOk) {
console.log('❌ New script path failed — investigate.');
process.exit(1);
} else {
console.log('❓ Unexpected state — review output above.');
process.exit(1);
}
62 changes: 62 additions & 0 deletions src/lib/__tests__/tmux-launch-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Unit tests for tmux-launch-script.ts
*/

import { beforeEach, describe, expect, test } from 'bun:test';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { writeTmuxLaunchScript } from '../tmux-launch-script.js';

const SPAWN_DIR = join(homedir(), '.genie', 'spawn-scripts');

describe('writeTmuxLaunchScript', () => {
beforeEach(() => {
// Clean up any leftover test scripts
try {
const files = require('node:fs').readdirSync(SPAWN_DIR);
for (const f of files) {
if (f.startsWith('test-') || f.startsWith('omni-')) {
rmSync(join(SPAWN_DIR, f));
}
}
} catch {
// dir may not exist yet
}
});

test('creates a script with shebang and command', () => {
const path = writeTmuxLaunchScript('test-worker', 'echo hello');
expect(existsSync(path)).toBe(true);

const content = readFileSync(path, 'utf-8');
expect(content).toStartWith('#!/bin/sh\n');
expect(content).toInclude('echo hello\n');
});

test('sanitizes workerId in filename', () => {
const path = writeTmuxLaunchScript('worker/with:bad@chars', 'echo hello');
const basename = path.split('/').pop()!;
expect(basename).toMatch(/^worker-with-bad-chars-/);
expect(basename).toEndWith('.sh');
});

test('creates script in ~/.genie/spawn-scripts', () => {
const path = writeTmuxLaunchScript('test-worker', 'echo hello');
expect(path).toStartWith(SPAWN_DIR);
});

test('sets executable permissions', () => {
const path = writeTmuxLaunchScript('test-worker', 'echo hello');
const stats = require('node:fs').statSync(path);
// Check owner-execute bit
expect(stats.mode & 0o100).toBe(0o100);
});

test('preserves complex commands with quotes and backticks', () => {
const cmd = `OMNI_API_KEY='sk-123' claude --permission-mode 'auto' --system-prompt 'Use \`git\` (👍) for (instance: x)'`;
const path = writeTmuxLaunchScript('omni-chat-123', cmd);
const content = readFileSync(path, 'utf-8');
expect(content).toInclude(`${cmd}\n`);
});
});
29 changes: 29 additions & 0 deletions src/lib/tmux-launch-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Tmux Launch Script — Write a temporary shell script for complex tmux spawns.
*
* Long commands with nested quotes, backticks, emojis, and JSON escapes corrupt
* when passed through `tmux send-keys`. Writing the command to a script file and
* sourcing it from the pane removes the escaping surface and keeps the launch
* stable.
*/

import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

/**
* Write a temporary launch script for complex tmux spawns.
*
* @param workerId — identifier used in the filename (e.g. agent or chat id)
* @param fullCommand — the complete shell command to execute
* @returns absolute path to the written script
*/
export function writeTmuxLaunchScript(workerId: string, fullCommand: string): string {
const dir = join(homedir(), '.genie', 'spawn-scripts');
mkdirSync(dir, { recursive: true });
const safeId = workerId.replace(/[^a-zA-Z0-9._-]/g, '-');
const scriptPath = join(dir, `${safeId}-${Date.now().toString(36)}.sh`);
writeFileSync(scriptPath, `#!/bin/sh\n${fullCommand}\n`, { mode: 0o700 });
chmodSync(scriptPath, 0o700);
return scriptPath;
}
2 changes: 1 addition & 1 deletion src/lib/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ export async function isPaneProcessRunning(
const exec: ExecSyncFn = execSyncFn ?? ((await import('node:child_process')).execSync as ExecSyncFn);
// Check direct children and grandchildren for the target process name
const output = exec(
`pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; done; true`,
`pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; ps -p "$cpid" -o comm= 2>/dev/null; done; true`,
{ encoding: 'utf-8', timeout: 5000 },
);
return output.toLowerCase().includes(processName.toLowerCase());
Expand Down
24 changes: 10 additions & 14 deletions src/services/executors/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,12 @@ describe('buildOmniSpawnParams', () => {
});

test('emits resume (not sessionId) when resumeClaudeSessionId is set', () => {
// Operator-facing invariant: omni-spawned chats are permanent. When the
// bridge respawns a per-chat agent and we have a prior Claude session id
// for that (agent, chat), buildOmniSpawnParams must surface it as `resume`
// so buildLaunchCommand emits `--resume <id>` and Claude reattaches to
// the same JSONL transcript.
// Operator-facing invariant: omni-bridged chats are permanent. When the
// bridge respawns a per-chat agent and we have a prior Claude session id,
// buildOmniSpawnParams must surface it as `resume` so buildLaunchCommand
// emits `--resume <id>` and Claude reattaches to the existing JSONL.
// If the JSONL is missing, launchOmniProcessInPane detects the silent
// failure and falls back to a fresh --session-id automatically.
const priorClaudeSessionId = 'b25fb825-3695-4aa1-bcdf-7a4c20dc66c8';
const params = buildOmniSpawnParams(
'simone',
Expand All @@ -201,18 +202,13 @@ describe('buildOmniSpawnParams', () => {
priorClaudeSessionId,
);
expect(params.resume).toBe(priorClaudeSessionId);
// sessionId must be omitted on resume — they are mutually exclusive in
// buildLaunchCommand. A leftover sessionId would cause a noisy fallback
// when `resume` is consumed first.
expect(params.sessionId).toBeUndefined();
});

test('falls back to fresh sessionId when resumeClaudeSessionId is undefined', () => {
// Backward compat: existing callers (and the first-ever spawn for a chat)
// pass no resume id and must still get a generated sessionId for
// `--session-id <new-uuid>`. The newly-generated id is what gets
// persisted to executors.claude_session_id so the *next* respawn can
// resume it.
test('emits sessionId (not resume) when resumeClaudeSessionId is undefined', () => {
// First-ever spawn for a chat: no prior session, so we mint a fresh UUID
// for --session-id. This id is persisted to executors.claude_session_id
// so the next respawn can resume it via --resume.
const params = buildOmniSpawnParams('simone', 'chat123', fakeEntry, {}, 'first message');
expect(params.resume).toBeUndefined();
expect(params.sessionId).toBeDefined();
Expand Down
59 changes: 41 additions & 18 deletions src/services/executors/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { signOmniRequest } from '../../lib/omni-signature.js';
import { buildLaunchCommand } from '../../lib/provider-adapters.js';
import type { SpawnParams } from '../../lib/provider-adapters.js';
import { shellQuote } from '../../lib/team-lead-command.js';
import { writeTmuxLaunchScript } from '../../lib/tmux-launch-script.js';
import {
capturePaneContent,
ensureTeamWindow,
Expand Down Expand Up @@ -152,13 +153,11 @@ async function lookupChatName(chatId: string, _instanceId: string): Promise<stri
/**
* Build SpawnParams from omni bridge context so we can delegate to buildLaunchCommand().
*
* @param resumeClaudeSessionId — when set, emits `--resume <id>` instead of
* `--session-id <new-uuid>`. Used by the omni bridge to attach a freshly-
* spawned tmux pane to the same Claude conversation that handled this chat
* before a crash/restart, so per-chat history persists across executor death.
* The two flags are mutually exclusive in `buildLaunchCommand` (see
* provider-adapters.ts) — `resume` wins when both are set, but we omit
* `sessionId` when resuming for clarity.
* @param resumeClaudeSessionId — when set, emits `--resume <id>` so the omni
* bridge reattaches to the same Claude conversation that handled this chat
* before a crash/restart. If the JSONL is missing (cleanup, fresh machine),
* `--resume` will silently fail; `launchOmniProcessInPane` detects that and
* falls back to a fresh `--session-id <new-uuid>` automatically.
*/
export function buildOmniSpawnParams(
agentName: string,
Expand Down Expand Up @@ -245,18 +244,42 @@ export class ClaudeCodeOmniExecutor implements IExecutor {
resumeClaudeSessionId: string | undefined,
): Promise<string | undefined> {
const omniEnv: Record<string, string> = { ...env, GENIE_OMNI_CHAT_ID: chatId, GENIE_OMNI_AGENT: agentName };

const sendToPane = async (params: SpawnParams): Promise<void> => {
const launch = buildLaunchCommand(params);
const allEnv = { ...omniEnv, ...launch.env };
const envPrefix = Object.entries(allEnv)
.map(([k, v]) => `${k}=${shellQuote(v)}`)
.join(' ');
const cmd = envPrefix ? `${envPrefix} ${launch.command}` : launch.command;
const scriptPath = writeTmuxLaunchScript(`omni-${chatId}`, cmd);
await executeTmux(`send-keys -t '${paneId}' "source ${scriptPath}" Enter`);
};

const params = buildOmniSpawnParams(agentName, chatId, entry, omniEnv, initialMessage, resumeClaudeSessionId);
const claudeSessionId = resumeClaudeSessionId ?? params.sessionId ?? undefined;
const launch = buildLaunchCommand(params);

// Merge omni-specific env vars with those produced by buildLaunchCommand.
const allEnv = { ...omniEnv, ...launch.env };
const envPrefix = Object.entries(allEnv)
.map(([k, v]) => `${k}=${shellQuote(v)}`)
.join(' ');
const cmd = envPrefix ? `${envPrefix} ${launch.command}` : launch.command;
await executeTmux(`send-keys -t '${paneId}' ${shellQuote(cmd)} Enter`);
return claudeSessionId;
await sendToPane(params);

if (resumeClaudeSessionId) {
// --resume silently fails when the JSONL is missing (e.g. cleanup, fresh
// machine): Claude exits immediately and the pane returns to the shell
// without printing an error. Detect this by polling for the process after
// a short settle window and, on failure, fall back to a fresh session so
// the inbound message is not lost.
await new Promise((r) => setTimeout(r, 3000));
const processName = resolveOmniPaneProcessName(entry.provider);
const resumed = await isPaneProcessRunning(paneId, processName);
if (!resumed) {
console.warn(
`[claude-code] --resume ${resumeClaudeSessionId} failed for chat ${chatId} — JSONL likely missing. Falling back to fresh session.`,
);
const freshParams = buildOmniSpawnParams(agentName, chatId, entry, omniEnv, initialMessage);
await sendToPane(freshParams);
return freshParams.sessionId;
}
return resumeClaudeSessionId;
}

return params.sessionId;
}

async spawn(
Expand Down
Loading