chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
|
||||
export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const readFileTool: ToolDefinition = {
|
||||
name: 'fs_read_file',
|
||||
label: 'Read File',
|
||||
description:
|
||||
'Read the contents of a file. Path is resolved relative to the sandbox base directory.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
encoding: Type.Optional(
|
||||
Type.String({ description: 'Encoding: utf8 (default), base64, hex' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, encoding } = params as { path: string; encoding?: string };
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardPath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isFile()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
if (info.size > MAX_READ_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: file too large (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||
const content = await readFile(safePath, { encoding: enc });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: String(content) }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const writeFileTool: ToolDefinition = {
|
||||
name: 'fs_write_file',
|
||||
label: 'Write File',
|
||||
description:
|
||||
'Write content to a file. Path is resolved relative to the sandbox base directory. Overwrites existing file.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
content: Type.String({ description: 'Content to write' }),
|
||||
encoding: Type.Optional(Type.String({ description: 'Encoding: utf8 (default), base64' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, content, encoding } = params as {
|
||||
path: string;
|
||||
content: string;
|
||||
encoding?: string;
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardWritePath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: content too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||
await writeFile(safePath, content, { encoding: enc });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `File written successfully: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listDirectoryTool: ToolDefinition = {
|
||||
name: 'fs_list_directory',
|
||||
label: 'List Directory',
|
||||
description: 'List files and directories at a given path within the sandbox base directory.',
|
||||
parameters: Type.Object({
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Directory path (relative to sandbox base). Defaults to base directory.',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path } = params as { path?: string };
|
||||
const target = path ?? '.';
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardPath(target, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isDirectory()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a directory: ${target}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const entries = await readdir(safePath, { withFileTypes: true });
|
||||
const items = entries.map((e) => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file',
|
||||
}));
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(items, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error listing directory: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const editFileTool: ToolDefinition = {
|
||||
name: 'fs_edit_file',
|
||||
label: 'Edit File',
|
||||
description:
|
||||
'Make targeted text replacements in a file. Each edit replaces an exact match of oldText with newText. ' +
|
||||
'All edits are matched against the original file content (not incrementally). ' +
|
||||
'Each oldText must be unique in the file and edits must not overlap.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
edits: Type.Array(
|
||||
Type.Object({
|
||||
oldText: Type.String({
|
||||
description: 'Exact text to find and replace (must be unique in the file)',
|
||||
}),
|
||||
newText: Type.String({ description: 'Replacement text' }),
|
||||
}),
|
||||
{ description: 'One or more targeted replacements', minItems: 1 },
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, edits } = params as {
|
||||
path: string;
|
||||
edits: Array<{ oldText: string; newText: string }>;
|
||||
};
|
||||
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardPath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isFile()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
if (info.size > MAX_READ_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: file too large for editing (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(safePath, { encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate all edits before applying any
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i]!;
|
||||
const occurrences = content.split(edit.oldText).length - 1;
|
||||
if (occurrences === 0) {
|
||||
errors.push(`Edit ${i + 1}: oldText not found in file`);
|
||||
} else if (occurrences > 1) {
|
||||
errors.push(`Edit ${i + 1}: oldText matches ${occurrences} locations (must be unique)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping edits
|
||||
if (errors.length === 0) {
|
||||
const positions = edits.map((edit, i) => ({
|
||||
index: i,
|
||||
start: content.indexOf(edit.oldText),
|
||||
end: content.indexOf(edit.oldText) + edit.oldText.length,
|
||||
}));
|
||||
positions.sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
if (positions[i]!.start < positions[i - 1]!.end) {
|
||||
errors.push(
|
||||
`Edits ${positions[i - 1]!.index + 1} and ${positions[i]!.index + 1} overlap`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Edit validation failed:\n${errors.join('\n')}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply edits: process from end to start to preserve positions
|
||||
const positions = edits.map((edit) => ({
|
||||
edit,
|
||||
start: content.indexOf(edit.oldText),
|
||||
}));
|
||||
positions.sort((a, b) => b.start - a.start); // reverse order
|
||||
|
||||
let result = content;
|
||||
for (const { edit } of positions) {
|
||||
result = result.replace(edit.oldText, edit.newText);
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(result, 'utf8') > MAX_WRITE_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: resulting file too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(safePath, result, { encoding: 'utf8' });
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `File edited successfully: ${path} (${edits.length} edit(s) applied)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [readFileTool, writeFileTool, listDirectoryTool, editFileTool];
|
||||
}
|
||||
Reference in New Issue
Block a user