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
4 changes: 2 additions & 2 deletions src/genie-commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,8 @@ async function configurePromptMode(config: GenieConfig, quick: boolean): Promise
return config;
}

console.log(' append — Uses --append-system-prompt (preserves Claude Code default system prompt)');
console.log(' system — Uses --system-prompt (replaces Claude Code default system prompt)');
console.log(' append — Uses --append-system-prompt-file (preserves Claude Code default system prompt)');
console.log(' system — Uses --system-prompt-file (replaces Claude Code default system prompt)');
console.log();

const promptMode = await select({
Expand Down
77 changes: 76 additions & 1 deletion src/lib/provider-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe('buildClaudeCommand', () => {
expect(result.command).not.toContain("--system-prompt-file '/path");
});

it('does not include prompt file flags when systemPromptFile is not set', () => {
it('does not include prompt file flags when neither systemPromptFile nor systemPrompt is set', () => {
const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
Expand All @@ -153,6 +153,81 @@ describe('buildClaudeCommand', () => {
expect(result.command).not.toContain('--system-prompt-file');
expect(result.command).not.toContain('--append-system-prompt-file');
});

it('writes systemPrompt to temp file and uses --append-system-prompt-file', () => {
const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
role: 'implementor',
systemPrompt: 'You are an implementor agent.',
});
expect(result.command).toContain('--append-system-prompt-file');
expect(result.command).toContain('/tmp/genie-prompts/implementor-');
// Must NOT contain inline --append-system-prompt (without -file)
expect(result.command).not.toMatch(/--append-system-prompt(?!-file)/);
});

it('writes systemPrompt to temp file with --system-prompt-file when promptMode is "system"', () => {
const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
role: 'implementor',
systemPrompt: 'You are an implementor agent.',
promptMode: 'system',
});
expect(result.command).toContain('--system-prompt-file');
expect(result.command).not.toContain('--append-system-prompt-file');
expect(result.command).toContain('/tmp/genie-prompts/implementor-');
});

it('never emits inline --system-prompt or --append-system-prompt flags', () => {
const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
role: 'tester',
systemPrompt: 'Multi-line prompt\nwith ```code blocks```\nand special chars: $VAR "quotes"',
});
// Should use file-based flag
expect(result.command).toContain('--append-system-prompt-file');
// Must NOT contain inline prompt flags (without -file suffix)
expect(result.command).not.toMatch(/--append-system-prompt(?!-file)/);
expect(result.command).not.toMatch(/--system-prompt(?!-file)/);
});

it('uses "agent" as fallback role in temp file name when no role set', () => {
const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
systemPrompt: 'Some prompt',
});
expect(result.command).toContain('/tmp/genie-prompts/agent-');
expect(result.command).toContain('--append-system-prompt-file');
});

it('merges systemPromptFile and systemPrompt into one temp file', () => {
const fs = require('node:fs');
const testFile = '/tmp/genie-prompts/test-agents.md';
fs.mkdirSync('/tmp/genie-prompts', { recursive: true });
fs.writeFileSync(testFile, 'User agent instructions');

const result = buildClaudeCommand({
provider: 'claude',
team: 'work',
role: 'implementor',
systemPromptFile: testFile,
systemPrompt: 'Built-in prompt',
});
expect(result.command).toContain('--append-system-prompt-file');
// Should reference the NEW temp file, not the original
expect(result.command).toContain('/tmp/genie-prompts/implementor-');

// Verify merged content
const match = result.command.match(/\/tmp\/genie-prompts\/implementor-[^']+/);
expect(match).toBeTruthy();
const content = fs.readFileSync(match![0], 'utf-8');
expect(content).toContain('User agent instructions');
expect(content).toContain('Built-in prompt');
});
Comment on lines +207 to +230

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

This test, and others in this file, create files and directories in /tmp/genie-prompts but do not clean them up. This creates side effects that can pollute the test environment and leave garbage on the file system.

Additionally, I noticed there's missing test coverage for the new logic that merges --append-system-prompt-file from extraArgs in buildClaudeCommand.

I recommend the following:

  1. Use test lifecycle hooks like afterEach or afterAll to clean up any files and directories created during the test run. You can use a unique temporary directory for each test run using fs.mkdtemp to make cleanup easier.
  2. Add new test cases to verify the extraArgs merging logic, especially a case with multiple --append-system-prompt-file flags to ensure it's handled correctly.

});

// ============================================================================
Expand Down
35 changes: 30 additions & 5 deletions src/lib/provider-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface SpawnParams {
resume?: string;
/** Path to a system prompt file (AGENTS.md). Emits --system-prompt-file or --append-system-prompt-file. */
systemPromptFile?: string;
/** Inline system prompt text (for built-ins without an AGENTS.md file). Emits --append-system-prompt or --system-prompt. */
/** Inline system prompt text (for built-ins without an AGENTS.md file). Written to temp file, emits --append-system-prompt-file or --system-prompt-file. */
systemPrompt?: string;
/** How to inject the system prompt file: 'system' replaces CC default, 'append' adds to it. */
promptMode?: 'system' | 'append';
Expand Down Expand Up @@ -232,12 +232,37 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand {

if (params.model) parts.push('--model', escapeShellArg(params.model));

if (params.systemPromptFile) {
if (params.systemPrompt) {
// Write built-in prompt to temp file — avoids shell escaping of complex content
const { mkdirSync, writeFileSync, readFileSync } = require('node:fs');
const { join } = require('node:path');
const dir = '/tmp/genie-prompts';
mkdirSync(dir, { recursive: true });
const ts = Date.now().toString(36);
const promptFile = join(dir, `${params.role || 'agent'}-${ts}.md`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Sanitize role before building temp prompt filename

Using params.role directly in join(dir, ${params.role || 'agent'}-${ts}.md) allows path traversal and absolute-path escapes when the role contains /, .., or starts with /. Agent names are only validated as non-empty elsewhere, so a role like ../../pwn causes prompt files to be written outside /tmp/genie-prompts (e.g. /pwn-<ts>.md), which is an unintended arbitrary file write and can also make worker startup fail on permission errors.

Useful? React with 👍 / 👎.


// If there is also a systemPromptFile (user agent), merge both
let content = params.systemPrompt;
if (params.systemPromptFile) {
content = `${readFileSync(params.systemPromptFile, 'utf-8')}\n\n${content}`;
}

// If extraArgs has --append-system-prompt-file, merge that too
if (params.extraArgs) {
const fileIdx = params.extraArgs.indexOf('--append-system-prompt-file');
if (fileIdx !== -1 && params.extraArgs[fileIdx + 1]) {
content = `${content}\n\n${readFileSync(params.extraArgs[fileIdx + 1], 'utf-8')}`;
// Remove the extra arg since we merged it
params.extraArgs.splice(fileIdx, 2);
}
}

writeFileSync(promptFile, content);
const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file';
parts.push(flag, escapeShellArg(promptFile));
} else if (params.systemPromptFile) {
const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file';
parts.push(flag, escapeShellArg(params.systemPromptFile));
} else if (params.systemPrompt) {
const flag = params.promptMode === 'system' ? '--system-prompt' : '--append-system-prompt';
parts.push(flag, escapeShellArg(params.systemPrompt));
}
Comment on lines +235 to 266

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.

high

This new implementation for handling system prompts has a few issues I'd recommend addressing:

  1. Resource Leak: Temporary files are created but never deleted. This will lead to an accumulation of files in the temp directory, which is a resource leak. A cleanup mechanism is crucial. I'd suggest modifying the LaunchCommand interface to return the path of the temporary file, making the caller responsible for cleanup after the command finishes execution.

  2. Bug with extraArgs: The logic to merge --append-system-prompt-file from extraArgs only handles the first occurrence due to using indexOf. If multiple flags are passed, the subsequent ones will be missed. This should be a loop to handle all occurrences.

  3. Hardcoded Temp Directory: The path /tmp is hardcoded. It's better to use os.tmpdir() for portability across different operating systems. You'll need to import { tmpdir } from 'node:os';.

  4. Code Style: require() calls are made inside the function. It's a better practice to use top-level ES module import statements for fs, path, and os for consistency and clarity.

Here's a suggested implementation that addresses points 2, 3, and 4 (while I've used require to make the suggestion self-contained, I recommend moving them to top-level imports). The resource leak (point 1) will require changes to the LaunchCommand interface, which is outside the scope of this suggestion but should be addressed.

  if (params.systemPrompt) {
    // Write built-in prompt to temp file — avoids shell escaping of complex content
    const { mkdirSync, writeFileSync, readFileSync } = require('node:fs');
    const { join } = require('node:path');
    const { tmpdir } = require('node:os');
    const dir = join(tmpdir(), 'genie-prompts');
    mkdirSync(dir, { recursive: true });
    const ts = Date.now().toString(36);
    const promptFile = join(dir, `${params.role || 'agent'}-${ts}.md`);

    // If there is also a systemPromptFile (user agent), merge both
    let content = params.systemPrompt;
    if (params.systemPromptFile) {
      content = readFileSync(params.systemPromptFile, 'utf-8') + '\n\n' + content;
    }

    // If extraArgs has --append-system-prompt-file, merge that too
    if (params.extraArgs) {
      let fileIdx;
      while ((fileIdx = params.extraArgs.indexOf('--append-system-prompt-file')) !== -1) {
        if (fileIdx + 1 < params.extraArgs.length) {
          content = content + '\n\n' + readFileSync(params.extraArgs[fileIdx + 1], 'utf-8');
          // Remove the flag and filename
          params.extraArgs.splice(fileIdx, 2);
        } else {
          // Malformed, just remove the flag
          params.extraArgs.splice(fileIdx, 1);
        }
      }
    }

    writeFileSync(promptFile, content);
    const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file';
    parts.push(flag, escapeShellArg(promptFile));
  } else if (params.systemPromptFile) {
    const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file';
    parts.push(flag, escapeShellArg(params.systemPromptFile));
  }


if (params.extraArgs) {
Expand Down
2 changes: 1 addition & 1 deletion src/types/genie-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const GenieConfigSchema = z.object({
councilPresets: z.record(z.string(), CouncilPresetSchema).optional(),
// Default council preset name
defaultCouncilPreset: z.string().optional(),
// Controls whether --system-prompt (replace CC default) or --append-system-prompt (preserve CC default) is used
// Controls whether --system-prompt-file (replace CC default) or --append-system-prompt-file (preserve CC default) is used
promptMode: z.enum(['append', 'system']).default('append'),
// Whether task leaders should auto-merge PRs to dev (default: false — leave PR open for human)
autoMergeDev: z.boolean().default(false),
Expand Down
Loading