diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index b6e49cf0..b22425c6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -1,5 +1,8 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; + +const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks. @@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. +${runtimeContextPrompt} + ## Workspace access & scope - You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. diff --git a/apps/cli/src/application/assistant/runtime-context.ts b/apps/cli/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/cli/src/application/assistant/runtime-context.ts @@ -0,0 +1,69 @@ +export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; +export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; + +export interface RuntimeContext { + platform: NodeJS.Platform; + osName: RuntimeOsName; + shellDialect: RuntimeShellDialect; + shellExecutable: string; +} + +export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; +} + +export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { + if (platform === 'win32') { + return { + platform, + osName: 'Windows', + shellDialect: 'windows-cmd', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'darwin') { + return { + platform, + osName: 'macOS', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'linux') { + return { + platform, + osName: 'Linux', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + return { + platform, + osName: 'Unknown', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; +} + +export function getRuntimeContextPrompt(runtime: RuntimeContext): string { + if (runtime.shellDialect === 'windows-cmd') { + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) +- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). +- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). +- Do not assume macOS/Linux command syntax when the runtime is Windows.`; + } + + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) +- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). +- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). +- Do not assume Windows command syntax when the runtime is POSIX.`; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index 814d9801..cd16f05e 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -1,11 +1,13 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; +import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); +const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"]+|['"]+$/g, ''); @@ -91,7 +93,7 @@ export async function executeCommand( cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: '/bin/sh', // use sh for cross-platform compatibility + shell: EXECUTION_SHELL, }); return { @@ -125,7 +127,7 @@ export function executeCommandSync( cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: '/bin/sh', + shell: EXECUTION_SHELL, }); return { diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index c0365c0f..96b50bb3 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -1,5 +1,8 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; + +const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything. @@ -150,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear - Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. +${runtimeContextPrompt} + ## Workspace Access & Scope - **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. - **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. - **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). **CRITICAL - When the user asks you to work with files outside ~/.rowboat:** -- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command). +- Follow the detected runtime platform above for shell syntax and filesystem path style. +- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS). +- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\\\Desktop\`). - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths. - NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`. - NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder"). -- NEVER ask what OS the user is on - they are on macOS. +- NEVER ask what OS the user is on if runtime platform is already available. - Load the \`organize-files\` skill for guidance on file organization tasks. ## Builtin Tools vs Shell Commands diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts @@ -0,0 +1,69 @@ +export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; +export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; + +export interface RuntimeContext { + platform: NodeJS.Platform; + osName: RuntimeOsName; + shellDialect: RuntimeShellDialect; + shellExecutable: string; +} + +export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; +} + +export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { + if (platform === 'win32') { + return { + platform, + osName: 'Windows', + shellDialect: 'windows-cmd', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'darwin') { + return { + platform, + osName: 'macOS', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'linux') { + return { + platform, + osName: 'Linux', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + return { + platform, + osName: 'Unknown', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; +} + +export function getRuntimeContextPrompt(runtime: RuntimeContext): string { + if (runtime.shellDialect === 'windows-cmd') { + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) +- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). +- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). +- Do not assume macOS/Linux command syntax when the runtime is Windows.`; + } + + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) +- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). +- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). +- Do not assume Windows command syntax when the runtime is POSIX.`; +} diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 870f6a0c..947f49a0 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -1,11 +1,13 @@ import { exec, execSync, spawn, ChildProcess } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList } from '../../config/security.js'; +import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); +const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"()]+|['"()]+$/g, ''); @@ -85,7 +87,7 @@ export async function executeCommand( cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: '/bin/sh', // use sh for cross-platform compatibility + shell: EXECUTION_SHELL, }); return { @@ -145,7 +147,7 @@ export function executeCommandAbortable( // Check if already aborted before spawning if (options?.signal?.aborted) { // Return a dummy process and a resolved result - const dummyProc = spawn('true', { shell: true }); + const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']); dummyProc.kill(); return { process: dummyProc, @@ -159,7 +161,7 @@ export function executeCommandAbortable( } const proc = spawn(command, [], { - shell: '/bin/sh', + shell: EXECUTION_SHELL, cwd: options?.cwd, detached: process.platform !== 'win32', // Create process group on Unix stdio: ['ignore', 'pipe', 'pipe'], @@ -273,7 +275,7 @@ export function executeCommandSync( cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: '/bin/sh', + shell: EXECUTION_SHELL, }); return {