diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9af436fa6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Ensure shell scripts use LF (fixes Docker entrypoint on Windows) +*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile index 7d48e15fe..722c80205 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,9 +80,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && GH_VERSION="2.63.2" \ && ARCH=$(uname -m) \ && case "$ARCH" in \ - x86_64) GH_ARCH="amd64" ;; \ - aarch64|arm64) GH_ARCH="arm64" ;; \ - *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + x86_64) GH_ARCH="amd64" ;; \ + aarch64|arm64) GH_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ esac \ && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \ && tar -xzf gh.tar.gz \ @@ -167,7 +167,9 @@ RUN git config --system --add safe.directory '*' && \ git config --system credential.helper '!gh auth git-credential' # Copy entrypoint script for fixing permissions on mounted volumes +# Strip CRLF if present (Windows checkout) so Linux can execute it COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Note: We stay as root here so entrypoint can fix permissions diff --git a/apps/server/src/providers/cli-provider.ts b/apps/server/src/providers/cli-provider.ts index ea636cb8e..b290253d6 100644 --- a/apps/server/src/providers/cli-provider.ts +++ b/apps/server/src/providers/cli-provider.ts @@ -606,7 +606,7 @@ export abstract class CliProvider extends BaseProvider { if (typeof options.prompt === 'string') { return { ...options, - prompt: `${systemText}\n\n---\n\n${options.prompt}`, + prompt: `${systemText}\n\n=== IMPLEMENTATION TASK ===\n\n${options.prompt}`, systemPrompt: undefined, }; } diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 6c0d98e75..7aea91720 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -14,7 +14,15 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { findCliInWsl, isWslAvailable } from '@automaker/platform'; +import { + findCliInWsl, + isWslAvailable, + windowsToWslPath, + spawnJSONLProcess, + execInWsl, + getAutomakerDir, + type SubprocessOptions, +} from '@automaker/platform'; import { CliProvider, type CliSpawnConfig, @@ -29,7 +37,7 @@ import type { ModelDefinition, ContentBlock, } from './types.js'; -import { validateBareModelId } from '@automaker/types'; +import { validateBareModelId, calculateReasoningTimeout } from '@automaker/types'; import { validateApiKey } from '../lib/auth-utils.js'; import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js'; import { @@ -42,7 +50,7 @@ import { CURSOR_MODEL_MAP, } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; -import { spawnJSONLProcess, execInWsl } from '@automaker/platform'; +import { randomBytes } from 'crypto'; // Create logger for this module const logger = createLogger('CursorProvider'); @@ -209,9 +217,9 @@ function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): str for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { const toolData = toolCall[key as keyof typeof toolCall] as | { - args?: unknown; - result?: { success?: unknown; rejected?: { reason: string } }; - } + args?: unknown; + result?: { success?: unknown; rejected?: { reason: string } }; + } | undefined; if (toolData?.result) { @@ -399,9 +407,14 @@ export class CursorProvider extends CliProvider { }; } + /** + * Max prompt length to pass as CLI arg. Longer prompts use a temp file to avoid + * platform command-line limits (~32KB Windows, ~2MB Linux). + */ + private static readonly PROMPT_ARG_MAX_LENGTH = 24 * 1024; + /** * Extract prompt text from ExecuteOptions - * Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues */ private extractPromptText(options: ExecuteOptions): string { if (typeof options.prompt === 'string') { @@ -416,13 +429,17 @@ export class CursorProvider extends CliProvider { } } - buildCliArgs(options: ExecuteOptions): string[] { + buildCliArgs( + options: ExecuteOptions, + extras?: { omitPrompt?: boolean } + ): string[] { // Model is already bare (no prefix) - validated by executeQuery const model = options.model || 'auto'; - // Build CLI arguments for cursor-agent - // NOTE: Prompt is NOT included here - it's passed via stdin to avoid - // shell escaping issues when content contains $(), backticks, etc. + // Build CLI arguments for cursor-agent. + // NOTE: cursor-agent does NOT support reading from stdin (unlike codex). Passing "-" + // as the prompt causes it to literally receive "-" as the message ("your message was + // just a dash"). We pass the prompt as the final positional argument instead. const cliArgs: string[] = []; // If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand @@ -437,10 +454,11 @@ export class CursorProvider extends CliProvider { '--stream-partial-output' // Real-time streaming ); - // In read-only mode, use --mode ask for Q&A style (no tools) - // Otherwise, add --force to allow file edits + // In read-only mode, use --mode ask for Q&A style (no tools). + // Add --trust so cursor-agent runs non-interactively without prompting (e.g., in Docker). + // Otherwise, add --force to allow file edits (also satisfies workspace trust). if (options.readOnly) { - cliArgs.push('--mode', 'ask'); + cliArgs.push('--mode', 'ask', '--trust'); } else { cliArgs.push('--force'); } @@ -455,12 +473,99 @@ export class CursorProvider extends CliProvider { cliArgs.push('--resume', options.sdkSessionId); } - // Use '-' to indicate reading prompt from stdin - cliArgs.push('-'); + // Pass prompt as final positional argument (cursor-agent has no stdin support for prompts). + // Omit when using temp-file fallback for long prompts. + if (!extras?.omitPrompt) { + cliArgs.push(this.extractPromptText(options)); + } return cliArgs; } + /** + * Escape a string for use inside a bash single-quoted literal. + * Single quotes are escaped as '\'' (end quote, escaped quote, start quote). + */ + private escapeForBashSingleQuoted(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; + } + + /** + * Build subprocess options when using a temp file for the prompt. + * Runs cursor-agent via bash -c '... "$(cat /path)"' to avoid command-line length limits. + */ + private buildSubprocessOptionsWithPromptFile( + options: ExecuteOptions, + cliArgs: string[], + promptFilePath: string + ): SubprocessOptions { + this.ensureCliDetected(); + if (!this.cliPath) { + throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); + } + + const cwd = options.cwd || process.cwd(); + const timeout = calculateReasoningTimeout(options.reasoningEffort, 120000); + + const filteredEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + filteredEnv[key] = value; + } + } + + const escapedArgs = cliArgs.map((a) => this.escapeForBashSingleQuoted(a)).join(' '); + const escapedPath = this.escapeForBashSingleQuoted(promptFilePath); + + if (this.useWsl && this.wslCliPath) { + const wslCwd = windowsToWslPath(cwd); + const wslPromptPath = windowsToWslPath(promptFilePath); + const wslEscapedPath = this.escapeForBashSingleQuoted(wslPromptPath); + const fullCommand = `${this.escapeForBashSingleQuoted(this.wslCliPath)} ${escapedArgs} "$(cat ${wslEscapedPath})"`; + + const wslArgs = this.wslDistribution + ? ['-d', this.wslDistribution, '--cd', wslCwd, 'bash', '-c', fullCommand] + : ['--cd', wslCwd, 'bash', '-c', fullCommand]; + + return { + command: 'wsl.exe', + args: wslArgs, + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + + // Native (Linux/macOS) or npx/direct on Windows + const cliPath = this.cliPath; + const fullCommand = `${this.escapeForBashSingleQuoted(cliPath)} ${escapedArgs} "$(cat ${escapedPath})"`; + + if (this.detectedStrategy === 'npx') { + // Run full npx invocation inside bash (fixes temp-file path: npx was incorrectly + // passing bash -c to cursor-agent instead of running the shell) + const npxArgsEscaped = this.npxArgs.map((a) => this.escapeForBashSingleQuoted(a)).join(' '); + const fullNpxCommand = `npx ${npxArgsEscaped} ${escapedArgs} "$(cat ${escapedPath})"`; + return { + command: 'bash', + args: ['-c', fullNpxCommand], + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + + return { + command: 'bash', + args: ['-c', fullCommand], + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + /** * Convert Cursor event to AutoMaker ProviderMessage format * Made public as required by CliProvider abstract method @@ -503,7 +608,7 @@ export class CursorProvider extends CliProvider { const toolCallKeys = Object.keys(toolCall); logger.warn( `[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` + - `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}` + `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}` ); return null; } @@ -861,23 +966,45 @@ export class CursorProvider extends CliProvider { const serverCount = Object.keys(options.mcpServers).length; logger.warn( `MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` + - `MCP support for Cursor will be added in a future release. ` + - `The configured MCP servers will be ignored for this execution.` + `MCP support for Cursor will be added in a future release. ` + + `The configured MCP servers will be ignored for this execution.` ); } // Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages) const effectiveOptions = this.embedSystemPromptIntoPrompt(options); - // Extract prompt text to pass via stdin (avoids shell escaping issues) + // Extract prompt text (cursor-agent expects it as positional arg, not stdin) const promptText = this.extractPromptText(effectiveOptions); - const cliArgs = this.buildCliArgs(effectiveOptions); - const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + const useTempFile = promptText.length > CursorProvider.PROMPT_ARG_MAX_LENGTH; + let promptFilePath: string | null = null; + + let cliArgs: string[]; + let subprocessOptions: ReturnType; + + if (useTempFile) { + // Temp file fallback for long prompts (avoids platform command-line limits) + const cwd = options.cwd || process.cwd(); + const automakerDir = getAutomakerDir(cwd); + const promptId = randomBytes(8).toString('hex'); + promptFilePath = path.join(automakerDir, `.cursor-prompt-${promptId}`); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.writeFileSync(promptFilePath, promptText, 'utf8'); + logger.debug( + `Prompt length ${promptText.length} exceeds limit; using temp file: ${promptFilePath}` + ); - // Pass prompt via stdin to avoid shell interpretation of special characters - // like $(), backticks, etc. that may appear in file content - subprocessOptions.stdinData = promptText; + cliArgs = this.buildCliArgs(effectiveOptions, { omitPrompt: true }); + subprocessOptions = this.buildSubprocessOptionsWithPromptFile( + options, + cliArgs, + promptFilePath + ); + } else { + cliArgs = this.buildCliArgs(effectiveOptions); + subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + } let sessionId: string | undefined; @@ -926,8 +1053,8 @@ export class CursorProvider extends CliProvider { .join(',') || 'unknown'; logger.info( `[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` + - (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') + - (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '') + (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') + + (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '') ); } } @@ -993,6 +1120,15 @@ export class CursorProvider extends CliProvider { ); } throw error; + } finally { + if (promptFilePath) { + try { + await fs.promises.unlink(promptFilePath); + logger.debug(`Removed temp prompt file: ${promptFilePath}`); + } catch { + // Ignore cleanup errors + } + } } } diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index da56165d0..acdea0b3c 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -6,6 +6,7 @@ map $http_upgrade $connection_upgrade { server { listen 80; + listen [::]:80; server_name localhost; root /usr/share/nginx/html; index index.html; diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2af347c7a..7d4f4010f 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -54,12 +54,17 @@ if [ ! -d "/home/automaker/.npm" ]; then fi chown -R automaker:automaker /home/automaker/.npm +# Ensure cursor-agent can create/use its config directory (~/.config/cursor) +# cursor-agent requires this directory for auth and cache; fails with EACCES if missing +CURSOR_CONFIG_DIR="/home/automaker/.config/cursor" +mkdir -p "$CURSOR_CONFIG_DIR" +chown -R automaker:automaker "$CURSOR_CONFIG_DIR" +chmod -R 700 "$CURSOR_CONFIG_DIR" + # If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file # On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage # The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent if [ -n "$CURSOR_AUTH_TOKEN" ]; then - CURSOR_CONFIG_DIR="/home/automaker/.config/cursor" - mkdir -p "$CURSOR_CONFIG_DIR" # Write auth.json with the access token cat > "$CURSOR_CONFIG_DIR/auth.json" << EOF { diff --git a/package-lock.json b/package-lock.json index 703bd1b98..c5602ce54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "husky": "9.1.7", "lint-staged": "^16.2.7", "prettier": "3.7.4", + "typescript": "^5.9.3", "vitest": "4.0.16" }, "engines": { @@ -1723,7 +1724,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -11831,6 +11832,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11852,6 +11854,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11873,6 +11876,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11894,6 +11898,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11915,6 +11920,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11936,6 +11942,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11957,6 +11964,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11978,6 +11986,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11999,6 +12008,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12020,6 +12030,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12041,6 +12052,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/package.json b/package.json index ed9285a0d..5b79db257 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "husky": "9.1.7", "lint-staged": "^16.2.7", "prettier": "3.7.4", + "typescript": "^5.9.3", "vitest": "4.0.16" }, "optionalDependencies": {