u
This commit is contained in:
@@ -47,9 +47,11 @@ export function buildIgnoreFilter(dir) {
|
|||||||
* Returns { entries: Array<{path,type,size}>, total, truncated }
|
* Returns { entries: Array<{path,type,size}>, total, truncated }
|
||||||
* - type: 'd' for directory, 'f' for file
|
* - type: 'd' for directory, 'f' for file
|
||||||
* - size: file size in bytes (null for directories)
|
* - size: file size in bytes (null for directories)
|
||||||
|
* Dot-directories (names starting with '.', e.g. .git) are skipped unless
|
||||||
|
* `includeDotDirs` is true.
|
||||||
* All paths are relative to ROOT_DIR and use forward slashes.
|
* All paths are relative to ROOT_DIR and use forward slashes.
|
||||||
*/
|
*/
|
||||||
export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
|
export function listDir(dir, { seek = 0, limit = 50, recursive = false, includeDotDirs = false } = {}) {
|
||||||
const ig = buildIgnoreFilter(dir);
|
const ig = buildIgnoreFilter(dir);
|
||||||
const allEntries = [];
|
const allEntries = [];
|
||||||
|
|
||||||
@@ -61,6 +63,8 @@ export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
// Skip dot-directories (e.g. .git) by default.
|
||||||
|
if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue;
|
||||||
const full = path.join(current, e.name);
|
const full = path.join(current, e.name);
|
||||||
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
|
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
|
||||||
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
|
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function buildSystemPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `You are Fido, a coding assistant.
|
return `You are Fido, a coding assistant.
|
||||||
You inspect files, list directories, and apply precise code changes within the workspace.
|
You inspect files, list directories, apply precise code changes, and run shell commands within the workspace.
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Root directory: ${ROOT_DIR}
|
Root directory: ${ROOT_DIR}
|
||||||
@@ -58,7 +58,7 @@ ${dirSection}
|
|||||||
|
|
||||||
## Available Tools & Guidelines
|
## Available Tools & Guidelines
|
||||||
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
|
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
|
||||||
- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore).
|
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: Explore workspace folders (respects .gitignore; dot-directories such as .git are excluded unless includeDotDirs=true).
|
||||||
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: Search file contents (regex by default) across the workspace; respects .gitignore and skips binary files. Returns matches with 0-indexed line numbers (usable directly as read_file's seek).
|
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: Search file contents (regex by default) across the workspace; respects .gitignore and skips binary files. Returns matches with 0-indexed line numbers (usable directly as read_file's seek).
|
||||||
- \`patch(operations)\`: Apply file updates or create files.
|
- \`patch(operations)\`: Apply file updates or create files.
|
||||||
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
|
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
|
||||||
@@ -67,6 +67,7 @@ ${dirSection}
|
|||||||
- \`mkdir(dirname)\`: Create directories (auto-recursive).
|
- \`mkdir(dirname)\`: Create directories (auto-recursive).
|
||||||
- \`rmdir(dirname)\`: Remove a directory and all its contents.
|
- \`rmdir(dirname)\`: Remove a directory and all its contents.
|
||||||
- \`rm(filename)\`: Delete an individual file.
|
- \`rm(filename)\`: Delete an individual file.
|
||||||
|
- \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100).
|
||||||
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
|
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|||||||
257
src/tools/bash-exec.js
Normal file
257
src/tools/bash-exec.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bash_exec — run a shell command in the workspace after explicit user approval.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - Runs `bash -c <command>` with cwd pinned to the workspace root, so
|
||||||
|
* relative paths start there. NOTE: this is convenience, not confinement —
|
||||||
|
* the shell can still touch absolute paths and escape with `cd ..`, so the
|
||||||
|
* interactive approval prompt below is the only guard.
|
||||||
|
* - Child processes run with color and interactivity disabled (see
|
||||||
|
* NONINTERACTIVE_ENV) and with stdin closed, so a command can never block
|
||||||
|
* on a terminal prompt.
|
||||||
|
* - Every command requires interactive approval via the same inquirer/chalk
|
||||||
|
* styling used by the ask_user tool.
|
||||||
|
* - A hard timeout kills the whole process group; output is trimmed to the
|
||||||
|
* last `tail_lines` lines before being returned to the model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { confirm } from '@inquirer/prompts';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { ROOT_DIR } from '../config.js';
|
||||||
|
|
||||||
|
const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command
|
||||||
|
const MAX_CAPTURE_CHARS = 1_000_000; // cap raw output kept in memory (~1 MB of text)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment overrides applied to every child process. These disable ANSI
|
||||||
|
* color and anything that would try to prompt, page, or open an editor, so a
|
||||||
|
* command run by the agent is fully non-interactive.
|
||||||
|
*/
|
||||||
|
const NONINTERACTIVE_ENV = {
|
||||||
|
// Color
|
||||||
|
NO_COLOR: '1',
|
||||||
|
FORCE_COLOR: '0',
|
||||||
|
CLICOLOR: '0',
|
||||||
|
CLICOLOR_FORCE: '0',
|
||||||
|
TERM: 'dumb',
|
||||||
|
// Non-interactive / automation signals
|
||||||
|
CI: '1',
|
||||||
|
DEBIAN_FRONTEND: 'noninteractive',
|
||||||
|
// Credentials, pagers, editors — never block waiting on the user
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
GIT_ASKPASS: 'echo',
|
||||||
|
SSH_ASKPASS: 'echo',
|
||||||
|
GIT_PAGER: 'cat',
|
||||||
|
GIT_EDITOR: 'true',
|
||||||
|
PAGER: 'cat',
|
||||||
|
// npm / npx
|
||||||
|
npm_config_color: 'false',
|
||||||
|
npm_config_yes: 'true',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_progress: 'false',
|
||||||
|
// Python
|
||||||
|
PYTHONUNBUFFERED: '1',
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the last `maxLines` lines of `text`.
|
||||||
|
* Returns { text, lines, omitted } where `lines` is the total before trimming
|
||||||
|
* and `omitted` is how many lines were dropped.
|
||||||
|
*/
|
||||||
|
function tailLines(text, maxLines) {
|
||||||
|
if (!text) return { text: '', lines: 0, omitted: 0 };
|
||||||
|
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
// Drop the single trailing empty element produced by a trailing newline.
|
||||||
|
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
||||||
|
const total = lines.length;
|
||||||
|
if (total <= maxLines) return { text: lines.join('\n'), lines: total, omitted: 0 };
|
||||||
|
return {
|
||||||
|
text: lines.slice(total - maxLines).join('\n'),
|
||||||
|
lines: total,
|
||||||
|
omitted: total - maxLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the user to approve a command before it is executed.
|
||||||
|
* Throws ToolError when the user declines or when no TTY is available.
|
||||||
|
*/
|
||||||
|
async function requestApproval(command, timeoutMs) {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
throw new ToolError(
|
||||||
|
'bash_exec requires interactive approval, but no TTY is attached. ' +
|
||||||
|
'Run fido in an interactive terminal to approve shell commands.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.yellow.bold(' ⚠ Fido wants to run a shell command'));
|
||||||
|
console.log(chalk.dim(` cwd: ${ROOT_DIR}`));
|
||||||
|
console.log(chalk.dim(` timeout: ${timeoutMs} ms`));
|
||||||
|
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let approved = false;
|
||||||
|
try {
|
||||||
|
approved = await confirm({
|
||||||
|
message: chalk.bold.yellow('Approve running this command?'),
|
||||||
|
default: false,
|
||||||
|
prefix: chalk.yellow('›'),
|
||||||
|
theme: {
|
||||||
|
prefix: chalk.yellow('›'),
|
||||||
|
style: { message: chalk.bold.yellow },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ctrl+C / EOF during the prompt → treat as a rejection.
|
||||||
|
approved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
if (!approved) {
|
||||||
|
throw new ToolError('Command rejected by the user.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn bash and collect stdout/stderr until the process exits or the timeout
|
||||||
|
* fires. Resolves (never rejects) with the captured output and exit metadata.
|
||||||
|
*/
|
||||||
|
function runCommand(command, timeoutMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const detached = process.platform !== 'win32';
|
||||||
|
const child = spawn('bash', ['-c', command], {
|
||||||
|
cwd: ROOT_DIR,
|
||||||
|
env: { ...process.env, ...NONINTERACTIVE_ENV },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let outputTruncated = false; // true if the size cap dropped earlier output
|
||||||
|
let timedOut = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
// Keep the *tail* of the output, not the head. The tool is designed to
|
||||||
|
// return the last `tail_lines` lines, so once the cap is exceeded we must
|
||||||
|
// retain the newest text and drop the oldest — capping the head would
|
||||||
|
// return stale lines from the middle of the stream and lose the real end.
|
||||||
|
const append = (prev, chunk) => {
|
||||||
|
const next = prev + chunk.toString('utf8');
|
||||||
|
if (next.length <= MAX_CAPTURE_CHARS) return next;
|
||||||
|
outputTruncated = true;
|
||||||
|
return next.slice(next.length - MAX_CAPTURE_CHARS);
|
||||||
|
};
|
||||||
|
child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); });
|
||||||
|
|
||||||
|
// Kill the whole process tree (bash and anything it spawned).
|
||||||
|
const killTree = () => {
|
||||||
|
try {
|
||||||
|
if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
|
||||||
|
else child.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
killTree();
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const finish = (payload) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on('error', (err) =>
|
||||||
|
finish({ code: null, signal: null, stdout, stderr, timedOut, outputTruncated, spawnError: err }));
|
||||||
|
child.on('close', (code, signal) =>
|
||||||
|
finish({ code, signal, stdout, stderr, timedOut, outputTruncated }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bashExecTool = defineTool({
|
||||||
|
name: 'bash_exec',
|
||||||
|
description:
|
||||||
|
'Run a bash command in the workspace root and return its output. ' +
|
||||||
|
'The command runs with color output and interactivity disabled, with stdin closed, ' +
|
||||||
|
'and is killed if it exceeds the timeout. ' +
|
||||||
|
'IMPORTANT: it requires explicit interactive user approval before executing, so the ' +
|
||||||
|
'user may decline. Output (stdout and stderr) is trimmed to the last `tail_lines` lines.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['command'],
|
||||||
|
properties: {
|
||||||
|
command: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The bash command to run. Executed via `bash -c` with cwd set to the workspace root.',
|
||||||
|
},
|
||||||
|
tail_lines: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Number of trailing output lines to return for stdout and stderr. Default: ${DEFAULT_TAIL_LINES}.`,
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Maximum runtime in milliseconds before the command is killed. Default: ${DEFAULT_TIMEOUT_MS}.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ command, tail_lines = DEFAULT_TAIL_LINES, timeout = DEFAULT_TIMEOUT_MS }) => {
|
||||||
|
if (!command || typeof command !== 'string' || !command.trim()) {
|
||||||
|
throw new ToolError('"command" must be a non-empty string.');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(tail_lines) || tail_lines < 1) {
|
||||||
|
throw new ToolError(`"tail_lines" must be a positive integer (received ${tail_lines}).`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(timeout) || timeout < 1) {
|
||||||
|
throw new ToolError(`"timeout" must be a positive integer of milliseconds (received ${timeout}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestApproval(command, timeout);
|
||||||
|
|
||||||
|
const { code, signal, stdout, stderr, timedOut, outputTruncated, spawnError } = await runCommand(command, timeout);
|
||||||
|
|
||||||
|
if (spawnError) {
|
||||||
|
throw new ToolError(`Failed to launch bash: ${spawnError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = tailLines(stdout, tail_lines);
|
||||||
|
const err = tailLines(stderr, tail_lines);
|
||||||
|
|
||||||
|
const omitted = [];
|
||||||
|
if (outputTruncated) omitted.push('earlier output (size cap)');
|
||||||
|
if (out.omitted) omitted.push(`${out.omitted} stdout line(s)`);
|
||||||
|
if (err.omitted) omitted.push(`${err.omitted} stderr line(s)`);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
command,
|
||||||
|
exitCode: code,
|
||||||
|
timedOut,
|
||||||
|
stdout: out.text,
|
||||||
|
stderr: err.text,
|
||||||
|
};
|
||||||
|
if (signal) result.signal = signal;
|
||||||
|
if (omitted.length) {
|
||||||
|
result.truncated = true;
|
||||||
|
result.truncationNote = `${omitted.join(' and ')} omitted; showing the last ${tail_lines} lines.`;
|
||||||
|
}
|
||||||
|
if (timedOut) {
|
||||||
|
result.note = `Command exceeded the ${timeout} ms timeout and was killed.`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { patchTool } from './patch.js';
|
|||||||
import { mkdirTool } from './mkdir.js';
|
import { mkdirTool } from './mkdir.js';
|
||||||
import { rmdirTool } from './rmdir.js';
|
import { rmdirTool } from './rmdir.js';
|
||||||
import { rmTool } from './rm.js';
|
import { rmTool } from './rm.js';
|
||||||
|
import { bashExecTool } from './bash-exec.js';
|
||||||
import { askUserTool } from './ask-user.js';
|
import { askUserTool } from './ask-user.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -21,6 +22,7 @@ export {
|
|||||||
mkdirTool,
|
mkdirTool,
|
||||||
rmdirTool,
|
rmdirTool,
|
||||||
rmTool,
|
rmTool,
|
||||||
|
bashExecTool,
|
||||||
askUserTool,
|
askUserTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,5 +34,6 @@ export const tools = [
|
|||||||
mkdirTool,
|
mkdirTool,
|
||||||
rmdirTool,
|
rmdirTool,
|
||||||
rmTool,
|
rmTool,
|
||||||
|
bashExecTool,
|
||||||
askUserTool,
|
askUserTool,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const listFilesTool = defineTool({
|
|||||||
description:
|
description:
|
||||||
'List the immediate contents of a directory within the workspace (non-recursive by default). ' +
|
'List the immediate contents of a directory within the workspace (non-recursive by default). ' +
|
||||||
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' +
|
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' +
|
||||||
|
'Dot-directories (names starting with ".", e.g. .git) are excluded by default; ' +
|
||||||
|
'set includeDotDirs=true to include them. ' +
|
||||||
'Set recursive=true to walk the full subtree. ' +
|
'Set recursive=true to walk the full subtree. ' +
|
||||||
'Use seek/limit for pagination. Paths are relative to the workspace root.',
|
'Use seek/limit for pagination. Paths are relative to the workspace root.',
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -33,9 +35,13 @@ export const listFilesTool = defineTool({
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'Maximum number of entries to return. Default: 50.',
|
description: 'Maximum number of entries to return. Default: 50.',
|
||||||
},
|
},
|
||||||
|
includeDotDirs: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'If true, include dot-directories (e.g. .git). Default: false.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50 }) => {
|
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50, includeDotDirs = false }) => {
|
||||||
if (!Number.isInteger(limit) || limit < 1) {
|
if (!Number.isInteger(limit) || limit < 1) {
|
||||||
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
|
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ export const listFilesTool = defineTool({
|
|||||||
if (!fs.statSync(absDir).isDirectory()) {
|
if (!fs.statSync(absDir).isDirectory()) {
|
||||||
throw new ToolError(`"${dirname}" is not a directory.`);
|
throw new ToolError(`"${dirname}" is not a directory.`);
|
||||||
}
|
}
|
||||||
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive });
|
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive, includeDotDirs });
|
||||||
const result = { dirname, recursive, entries, total, returned: entries.length, seek };
|
const result = { dirname, recursive, entries, total, returned: entries.length, seek };
|
||||||
if (truncated > 0) {
|
if (truncated > 0) {
|
||||||
result.truncated = truncated;
|
result.truncated = truncated;
|
||||||
|
|||||||
Reference in New Issue
Block a user