@lazyingart/agintiflow 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -3
- package/README.md +68 -15
- package/docker/sandbox.Dockerfile +8 -0
- package/package.json +11 -3
- package/public/app.js +644 -11
- package/public/index.html +165 -20
- package/public/styles.css +586 -13
- package/scripts/setup-agent-toolchain-docker.sh +51 -0
- package/scripts/smoke-coding-tools.js +138 -0
- package/scripts/smoke-toolchain-docker.js +185 -0
- package/scripts/smoke-web-api.js +217 -0
- package/src/agent-runner.js +286 -21
- package/src/artifact-tunnel.js +401 -0
- package/src/cli.js +47 -3
- package/src/command-policy.js +21 -0
- package/src/config.js +9 -2
- package/src/docker-sandbox.js +38 -8
- package/src/guardrails.js +31 -0
- package/src/model-client.js +300 -6
- package/src/model-routing.js +40 -0
- package/src/session-store.js +4 -0
- package/src/tool-wrappers.js +6 -0
- package/src/web-db.js +48 -5
- package/src/workspace-tools.js +438 -0
- package/web.js +265 -14
package/src/web-db.js
CHANGED
|
@@ -3,9 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { getModelPresets } from "./model-routing.js";
|
|
5
5
|
|
|
6
|
+
const PREFERENCES_SCHEMA_VERSION = 2;
|
|
7
|
+
|
|
6
8
|
function defaultPreferences(baseDir) {
|
|
7
9
|
const presets = getModelPresets();
|
|
8
10
|
return {
|
|
11
|
+
preferencesSchemaVersion: PREFERENCES_SCHEMA_VERSION,
|
|
9
12
|
routingMode: "smart",
|
|
10
13
|
provider: "deepseek",
|
|
11
14
|
model: presets.fast.model,
|
|
@@ -15,9 +18,11 @@ function defaultPreferences(baseDir) {
|
|
|
15
18
|
allowedDomains: "",
|
|
16
19
|
commandCwd: path.resolve(baseDir, ".."),
|
|
17
20
|
allowShellTool: true,
|
|
21
|
+
allowFileTools: true,
|
|
18
22
|
allowWrapperTools: false,
|
|
23
|
+
preferredWrapper: "codex",
|
|
19
24
|
wrapperTimeoutMs: 120000,
|
|
20
|
-
sandboxMode: "docker-
|
|
25
|
+
sandboxMode: "docker-workspace",
|
|
21
26
|
packageInstallPolicy: "prompt",
|
|
22
27
|
useDockerSandbox: true,
|
|
23
28
|
dockerSandboxImage: "agintiflow-sandbox:latest",
|
|
@@ -54,6 +59,14 @@ export class WebDatabase {
|
|
|
54
59
|
error TEXT
|
|
55
60
|
);
|
|
56
61
|
`);
|
|
62
|
+
this.migrate();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
migrate() {
|
|
66
|
+
const sessionColumns = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
67
|
+
if (!sessionColumns.some((column) => column.name === "title")) {
|
|
68
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT ''");
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
getPreferences() {
|
|
@@ -61,10 +74,23 @@ export class WebDatabase {
|
|
|
61
74
|
if (!row) return defaultPreferences(this.baseDir);
|
|
62
75
|
|
|
63
76
|
try {
|
|
64
|
-
|
|
77
|
+
const parsed = JSON.parse(row.value);
|
|
78
|
+
const preferences = {
|
|
65
79
|
...defaultPreferences(this.baseDir),
|
|
66
|
-
...
|
|
80
|
+
...parsed,
|
|
67
81
|
};
|
|
82
|
+
if ((parsed.preferencesSchemaVersion || 1) < PREFERENCES_SCHEMA_VERSION) {
|
|
83
|
+
preferences.preferencesSchemaVersion = PREFERENCES_SCHEMA_VERSION;
|
|
84
|
+
if (["host", "docker-readonly"].includes(parsed.sandboxMode || "")) {
|
|
85
|
+
preferences.sandboxMode = "docker-workspace";
|
|
86
|
+
preferences.useDockerSandbox = true;
|
|
87
|
+
}
|
|
88
|
+
if (parsed.packageInstallPolicy === "block") {
|
|
89
|
+
preferences.packageInstallPolicy = "prompt";
|
|
90
|
+
}
|
|
91
|
+
this.savePreferences(preferences);
|
|
92
|
+
}
|
|
93
|
+
return preferences;
|
|
68
94
|
} catch {
|
|
69
95
|
return defaultPreferences(this.baseDir);
|
|
70
96
|
}
|
|
@@ -87,12 +113,13 @@ export class WebDatabase {
|
|
|
87
113
|
this.db
|
|
88
114
|
.prepare(
|
|
89
115
|
`INSERT INTO sessions (
|
|
90
|
-
session_id, provider, model, goal, status, started_at, updated_at, ended_at, result, error
|
|
91
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
116
|
+
session_id, provider, model, goal, title, status, started_at, updated_at, ended_at, result, error
|
|
117
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
92
118
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
93
119
|
provider = excluded.provider,
|
|
94
120
|
model = excluded.model,
|
|
95
121
|
goal = excluded.goal,
|
|
122
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
96
123
|
status = excluded.status,
|
|
97
124
|
updated_at = excluded.updated_at,
|
|
98
125
|
ended_at = excluded.ended_at,
|
|
@@ -104,6 +131,7 @@ export class WebDatabase {
|
|
|
104
131
|
session.provider,
|
|
105
132
|
session.model,
|
|
106
133
|
session.goal,
|
|
134
|
+
session.title || "",
|
|
107
135
|
session.status,
|
|
108
136
|
session.startedAt,
|
|
109
137
|
updatedAt,
|
|
@@ -122,6 +150,7 @@ export class WebDatabase {
|
|
|
122
150
|
provider,
|
|
123
151
|
model,
|
|
124
152
|
goal,
|
|
153
|
+
title,
|
|
125
154
|
status,
|
|
126
155
|
started_at AS startedAt,
|
|
127
156
|
updated_at AS updatedAt,
|
|
@@ -143,6 +172,7 @@ export class WebDatabase {
|
|
|
143
172
|
provider,
|
|
144
173
|
model,
|
|
145
174
|
goal,
|
|
175
|
+
title,
|
|
146
176
|
status,
|
|
147
177
|
started_at AS startedAt,
|
|
148
178
|
updated_at AS updatedAt,
|
|
@@ -155,4 +185,17 @@ export class WebDatabase {
|
|
|
155
185
|
)
|
|
156
186
|
.all(limit);
|
|
157
187
|
}
|
|
188
|
+
|
|
189
|
+
renameSession(sessionId, title) {
|
|
190
|
+
const updatedAt = new Date().toISOString();
|
|
191
|
+
const result = this.db
|
|
192
|
+
.prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?")
|
|
193
|
+
.run(title, updatedAt, sessionId);
|
|
194
|
+
return result.changes > 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deleteSession(sessionId) {
|
|
198
|
+
const result = this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
|
|
199
|
+
return result.changes > 0;
|
|
200
|
+
}
|
|
158
201
|
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
5
|
+
|
|
6
|
+
export const WORKSPACE_TOOL_NAMES = ["list_files", "read_file", "search_files", "write_file", "apply_patch"];
|
|
7
|
+
export const WORKSPACE_WRITE_TOOL_NAMES = ["write_file", "apply_patch"];
|
|
8
|
+
|
|
9
|
+
const MAX_READ_BYTES = 220_000;
|
|
10
|
+
const MAX_WRITE_BYTES = 220_000;
|
|
11
|
+
const MAX_LIST_ENTRIES = 360;
|
|
12
|
+
const MAX_SEARCH_RESULTS = 80;
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 4;
|
|
14
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".sessions"]);
|
|
15
|
+
const SENSITIVE_EXTENSIONS = new Set([".key", ".pem", ".p12", ".pfx", ".crt", ".csr"]);
|
|
16
|
+
const SENSITIVE_BASENAMES = new Set([
|
|
17
|
+
".env",
|
|
18
|
+
".npmrc",
|
|
19
|
+
".pypirc",
|
|
20
|
+
"id_rsa",
|
|
21
|
+
"id_dsa",
|
|
22
|
+
"id_ecdsa",
|
|
23
|
+
"id_ed25519",
|
|
24
|
+
"credentials",
|
|
25
|
+
"credentials.json",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function normalizeRelative(relativePath) {
|
|
29
|
+
const normalized = relativePath.split(path.sep).join("/");
|
|
30
|
+
return normalized || ".";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workspaceRoot(config) {
|
|
34
|
+
return path.resolve(config.commandCwd || process.cwd());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isInside(root, target) {
|
|
38
|
+
const relative = path.relative(root, target);
|
|
39
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hashText(value) {
|
|
43
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashBuffer(value) {
|
|
47
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compactPreview(value, limit = 180) {
|
|
51
|
+
const text = redactSensitiveText(String(value ?? ""));
|
|
52
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}...`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sanitizePathInput(inputPath) {
|
|
56
|
+
const value = String(inputPath || ".").trim();
|
|
57
|
+
if (!value) return ".";
|
|
58
|
+
if (value.includes("\0")) throw new Error("Path contains a null byte.");
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeWorkspaceInputPath(rawPath) {
|
|
63
|
+
if (rawPath === "/workspace") return ".";
|
|
64
|
+
if (rawPath.startsWith("/workspace/")) return rawPath.slice("/workspace/".length) || ".";
|
|
65
|
+
return rawPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveWorkspacePath(config, inputPath = ".") {
|
|
69
|
+
const root = workspaceRoot(config);
|
|
70
|
+
const rawPath = sanitizePathInput(inputPath);
|
|
71
|
+
const workspacePath = normalizeWorkspaceInputPath(rawPath);
|
|
72
|
+
const absolutePath = path.resolve(root, workspacePath);
|
|
73
|
+
|
|
74
|
+
if (!isInside(root, absolutePath)) {
|
|
75
|
+
throw new Error(`Path escapes the configured workspace: ${rawPath}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
absolutePath,
|
|
81
|
+
relativePath: normalizeRelative(path.relative(root, absolutePath)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pathSegments(relativePath) {
|
|
86
|
+
return normalizeRelative(relativePath)
|
|
87
|
+
.split("/")
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isSensitivePath(relativePath) {
|
|
92
|
+
const segments = pathSegments(relativePath);
|
|
93
|
+
const basename = segments.at(-1) || "";
|
|
94
|
+
const lowerBase = basename.toLowerCase();
|
|
95
|
+
const lowerPath = normalizeRelative(relativePath).toLowerCase();
|
|
96
|
+
if (segments.includes(".git")) return true;
|
|
97
|
+
if (lowerBase === ".env" || lowerBase.startsWith(".env.")) return true;
|
|
98
|
+
if (SENSITIVE_BASENAMES.has(lowerBase)) return true;
|
|
99
|
+
if (SENSITIVE_EXTENSIONS.has(path.extname(lowerBase))) return true;
|
|
100
|
+
return /(^|\/)(secrets?|tokens?|passwords?|private[-_]?keys?|credentials?)(\/|\.|$)/i.test(lowerPath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isNodeModulesPath(relativePath) {
|
|
104
|
+
return pathSegments(relativePath).includes("node_modules");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pathPolicy(toolName, relativePath) {
|
|
108
|
+
const write = WORKSPACE_WRITE_TOOL_NAMES.includes(toolName);
|
|
109
|
+
if (isSensitivePath(relativePath)) {
|
|
110
|
+
return {
|
|
111
|
+
allowed: false,
|
|
112
|
+
reason: "Path is blocked because it may contain secrets or repository internals.",
|
|
113
|
+
category: "workspace-path",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (write && isNodeModulesPath(relativePath)) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: false,
|
|
119
|
+
reason: "Writes inside node_modules are blocked.",
|
|
120
|
+
category: "workspace-path",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { allowed: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function checkWorkspaceToolUse(toolName, args, config) {
|
|
127
|
+
if (!WORKSPACE_TOOL_NAMES.includes(toolName)) return { allowed: true };
|
|
128
|
+
if (!config.allowFileTools) {
|
|
129
|
+
return { allowed: false, reason: "Workspace file tools are disabled for this run.", category: "workspace-tools" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const target = resolveWorkspacePath(config, args.path || ".");
|
|
134
|
+
return pathPolicy(toolName, target.relativePath);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
allowed: false,
|
|
138
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
139
|
+
category: "workspace-path",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function summarizeWorkspaceTools(config) {
|
|
145
|
+
return {
|
|
146
|
+
enabled: Boolean(config.allowFileTools),
|
|
147
|
+
workspace: workspaceRoot(config),
|
|
148
|
+
tools: WORKSPACE_TOOL_NAMES,
|
|
149
|
+
writeTools: WORKSPACE_WRITE_TOOL_NAMES,
|
|
150
|
+
limits: {
|
|
151
|
+
maxReadBytes: MAX_READ_BYTES,
|
|
152
|
+
maxWriteBytes: MAX_WRITE_BYTES,
|
|
153
|
+
maxListEntries: MAX_LIST_ENTRIES,
|
|
154
|
+
maxSearchResults: MAX_SEARCH_RESULTS,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fileInfo(absolutePath, root) {
|
|
160
|
+
const stat = await fs.stat(absolutePath);
|
|
161
|
+
return {
|
|
162
|
+
path: normalizeRelative(path.relative(root, absolutePath)),
|
|
163
|
+
type: stat.isDirectory() ? "directory" : "file",
|
|
164
|
+
size: stat.size,
|
|
165
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function listFiles(config, args) {
|
|
170
|
+
const target = resolveWorkspacePath(config, args.path || ".");
|
|
171
|
+
const maxDepth = Math.min(Math.max(Number(args.maxDepth) || DEFAULT_MAX_DEPTH, 1), 8);
|
|
172
|
+
const limit = Math.min(Math.max(Number(args.limit) || MAX_LIST_ENTRIES, 1), MAX_LIST_ENTRIES);
|
|
173
|
+
const entries = [];
|
|
174
|
+
|
|
175
|
+
async function walk(currentPath, depth) {
|
|
176
|
+
if (entries.length >= limit) return;
|
|
177
|
+
const info = await fileInfo(currentPath, target.root);
|
|
178
|
+
const policy = pathPolicy("read_file", info.path);
|
|
179
|
+
if (!policy.allowed) return;
|
|
180
|
+
|
|
181
|
+
entries.push(info);
|
|
182
|
+
if (info.type !== "directory" || depth >= maxDepth) return;
|
|
183
|
+
|
|
184
|
+
const children = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
|
|
185
|
+
children.sort((a, b) => a.name.localeCompare(b.name));
|
|
186
|
+
for (const child of children) {
|
|
187
|
+
if (SKIP_DIRS.has(child.name)) continue;
|
|
188
|
+
await walk(path.join(currentPath, child.name), depth + 1);
|
|
189
|
+
if (entries.length >= limit) break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await walk(target.absolutePath, 0);
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
toolName: "list_files",
|
|
197
|
+
path: target.relativePath,
|
|
198
|
+
entries,
|
|
199
|
+
truncated: entries.length >= limit,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function readTextFile(target) {
|
|
204
|
+
const stat = await fs.stat(target.absolutePath);
|
|
205
|
+
if (!stat.isFile()) throw new Error(`Path is not a file: ${target.relativePath}`);
|
|
206
|
+
if (stat.size > MAX_READ_BYTES) throw new Error(`File is too large to read safely: ${target.relativePath}`);
|
|
207
|
+
|
|
208
|
+
const buffer = await fs.readFile(target.absolutePath);
|
|
209
|
+
if (buffer.includes(0)) throw new Error(`Binary files are not readable through this tool: ${target.relativePath}`);
|
|
210
|
+
return {
|
|
211
|
+
stat,
|
|
212
|
+
content: buffer.toString("utf8"),
|
|
213
|
+
hash: hashBuffer(buffer),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readFile(config, args) {
|
|
218
|
+
const target = resolveWorkspacePath(config, args.path);
|
|
219
|
+
const { stat, content, hash } = await readTextFile(target);
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
toolName: "read_file",
|
|
223
|
+
path: target.relativePath,
|
|
224
|
+
bytes: stat.size,
|
|
225
|
+
sha256: hash,
|
|
226
|
+
content: redactSensitiveText(content),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function searchFiles(config, args) {
|
|
231
|
+
const target = resolveWorkspacePath(config, args.path || ".");
|
|
232
|
+
const query = String(args.query || "").trim();
|
|
233
|
+
if (query.length < 2) throw new Error("Search query must be at least 2 characters.");
|
|
234
|
+
|
|
235
|
+
const caseSensitive = Boolean(args.caseSensitive);
|
|
236
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
237
|
+
const maxResults = Math.min(Math.max(Number(args.maxResults) || MAX_SEARCH_RESULTS, 1), MAX_SEARCH_RESULTS);
|
|
238
|
+
const results = [];
|
|
239
|
+
|
|
240
|
+
async function visit(currentPath, depth = 0) {
|
|
241
|
+
if (results.length >= maxResults || depth > DEFAULT_MAX_DEPTH + 2) return;
|
|
242
|
+
const stat = await fs.stat(currentPath).catch(() => null);
|
|
243
|
+
if (!stat) return;
|
|
244
|
+
|
|
245
|
+
const relativePath = normalizeRelative(path.relative(target.root, currentPath));
|
|
246
|
+
const policy = pathPolicy("read_file", relativePath);
|
|
247
|
+
if (!policy.allowed) return;
|
|
248
|
+
|
|
249
|
+
if (stat.isDirectory()) {
|
|
250
|
+
const children = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
|
|
251
|
+
children.sort((a, b) => a.name.localeCompare(b.name));
|
|
252
|
+
for (const child of children) {
|
|
253
|
+
if (SKIP_DIRS.has(child.name)) continue;
|
|
254
|
+
await visit(path.join(currentPath, child.name), depth + 1);
|
|
255
|
+
if (results.length >= maxResults) break;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!stat.isFile() || stat.size > MAX_READ_BYTES) return;
|
|
261
|
+
const buffer = await fs.readFile(currentPath).catch(() => null);
|
|
262
|
+
if (!buffer || buffer.includes(0)) return;
|
|
263
|
+
const lines = buffer.toString("utf8").split(/\r?\n/);
|
|
264
|
+
lines.forEach((line, index) => {
|
|
265
|
+
if (results.length >= maxResults) return;
|
|
266
|
+
const haystack = caseSensitive ? line : line.toLowerCase();
|
|
267
|
+
if (haystack.includes(needle)) {
|
|
268
|
+
results.push({
|
|
269
|
+
path: relativePath,
|
|
270
|
+
line: index + 1,
|
|
271
|
+
text: compactPreview(line, 240),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await visit(target.absolutePath);
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
toolName: "search_files",
|
|
281
|
+
query: compactPreview(query, 120),
|
|
282
|
+
path: target.relativePath,
|
|
283
|
+
results,
|
|
284
|
+
truncated: results.length >= maxResults,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function compactDiff(relativePath, beforeText, afterText) {
|
|
289
|
+
const beforeLines = String(beforeText || "").split("\n");
|
|
290
|
+
const afterLines = String(afterText || "").split("\n");
|
|
291
|
+
let prefix = 0;
|
|
292
|
+
while (prefix < beforeLines.length && prefix < afterLines.length && beforeLines[prefix] === afterLines[prefix]) {
|
|
293
|
+
prefix += 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let suffix = 0;
|
|
297
|
+
while (
|
|
298
|
+
suffix + prefix < beforeLines.length &&
|
|
299
|
+
suffix + prefix < afterLines.length &&
|
|
300
|
+
beforeLines[beforeLines.length - suffix - 1] === afterLines[afterLines.length - suffix - 1]
|
|
301
|
+
) {
|
|
302
|
+
suffix += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const beforeChanged = beforeLines.slice(prefix, beforeLines.length - suffix);
|
|
306
|
+
const afterChanged = afterLines.slice(prefix, afterLines.length - suffix);
|
|
307
|
+
const maxLines = 28;
|
|
308
|
+
const lines = [`--- a/${relativePath}`, `+++ b/${relativePath}`, `@@ line ${prefix + 1} @@`];
|
|
309
|
+
|
|
310
|
+
beforeChanged.slice(0, maxLines).forEach((line) => lines.push(`-${redactSensitiveText(line)}`));
|
|
311
|
+
afterChanged.slice(0, maxLines).forEach((line) => lines.push(`+${redactSensitiveText(line)}`));
|
|
312
|
+
if (beforeChanged.length + afterChanged.length > maxLines * 2) lines.push("... diff truncated ...");
|
|
313
|
+
return lines.join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function writeChange(target, nextContent, action, details = {}) {
|
|
317
|
+
const content = String(nextContent ?? "");
|
|
318
|
+
if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
319
|
+
throw new Error(`Write is too large for safe workspace tools: ${target.relativePath}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await fs.mkdir(path.dirname(target.absolutePath), { recursive: true });
|
|
323
|
+
let beforeText = "";
|
|
324
|
+
let beforeHash = null;
|
|
325
|
+
let beforeBytes = 0;
|
|
326
|
+
let existed = false;
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const before = await fs.readFile(target.absolutePath);
|
|
330
|
+
if (before.length > MAX_READ_BYTES) throw new Error(`Existing file is too large to overwrite safely: ${target.relativePath}`);
|
|
331
|
+
if (before.includes(0)) throw new Error(`Binary files cannot be overwritten through this tool: ${target.relativePath}`);
|
|
332
|
+
existed = true;
|
|
333
|
+
beforeText = before.toString("utf8");
|
|
334
|
+
beforeHash = hashBuffer(before);
|
|
335
|
+
beforeBytes = before.length;
|
|
336
|
+
} catch {
|
|
337
|
+
// Missing files are valid for create/overwrite actions.
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await fs.writeFile(target.absolutePath, content, "utf8");
|
|
341
|
+
const afterBuffer = await fs.readFile(target.absolutePath);
|
|
342
|
+
const afterText = afterBuffer.toString("utf8");
|
|
343
|
+
const afterHash = hashBuffer(afterBuffer);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
action,
|
|
348
|
+
path: target.relativePath,
|
|
349
|
+
beforeHash,
|
|
350
|
+
afterHash,
|
|
351
|
+
beforeBytes,
|
|
352
|
+
afterBytes: afterBuffer.length,
|
|
353
|
+
created: !existed,
|
|
354
|
+
diff: compactDiff(target.relativePath, beforeText, afterText),
|
|
355
|
+
contentSha256: hashText(content),
|
|
356
|
+
...details,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function writeFile(config, args) {
|
|
361
|
+
const target = resolveWorkspacePath(config, args.path);
|
|
362
|
+
const mode = args.mode === "overwrite" ? "overwrite" : "create";
|
|
363
|
+
const exists = await fs
|
|
364
|
+
.stat(target.absolutePath)
|
|
365
|
+
.then(() => true)
|
|
366
|
+
.catch(() => false);
|
|
367
|
+
if (exists && mode !== "overwrite") {
|
|
368
|
+
throw new Error(`File already exists. Use mode=overwrite to modify it: ${target.relativePath}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const change = await writeChange(target, args.content || "", mode === "overwrite" ? "overwrite_file" : "create_file", {
|
|
372
|
+
mode,
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
toolName: "write_file",
|
|
377
|
+
path: target.relativePath,
|
|
378
|
+
change,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function applyPatch(config, args) {
|
|
383
|
+
const target = resolveWorkspacePath(config, args.path);
|
|
384
|
+
const { content: beforeText } = await readTextFile(target);
|
|
385
|
+
const search = String(args.search || "");
|
|
386
|
+
const replace = String(args.replace ?? "");
|
|
387
|
+
if (!search) throw new Error("Patch search text is required.");
|
|
388
|
+
|
|
389
|
+
const matches = beforeText.split(search).length - 1;
|
|
390
|
+
if (matches === 0) throw new Error(`Patch search text was not found in ${target.relativePath}.`);
|
|
391
|
+
const expected = Number(args.expectedReplacements);
|
|
392
|
+
if (Number.isFinite(expected) && expected > 0 && matches !== expected) {
|
|
393
|
+
throw new Error(`Patch expected ${expected} replacement(s), found ${matches}.`);
|
|
394
|
+
}
|
|
395
|
+
if (matches > 20) throw new Error(`Patch would replace too many sections (${matches}).`);
|
|
396
|
+
|
|
397
|
+
const afterText = beforeText.split(search).join(replace);
|
|
398
|
+
const change = await writeChange(target, afterText, "apply_patch", {
|
|
399
|
+
replacements: matches,
|
|
400
|
+
searchPreview: compactPreview(search),
|
|
401
|
+
replacePreview: compactPreview(replace),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
toolName: "apply_patch",
|
|
407
|
+
path: target.relativePath,
|
|
408
|
+
change,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function executeWorkspaceTool(toolName, args, config) {
|
|
413
|
+
const guard = checkWorkspaceToolUse(toolName, args, config);
|
|
414
|
+
if (!guard.allowed) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
blocked: true,
|
|
418
|
+
reason: guard.reason,
|
|
419
|
+
category: guard.category,
|
|
420
|
+
toolName,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
switch (toolName) {
|
|
425
|
+
case "list_files":
|
|
426
|
+
return listFiles(config, args);
|
|
427
|
+
case "read_file":
|
|
428
|
+
return readFile(config, args);
|
|
429
|
+
case "search_files":
|
|
430
|
+
return searchFiles(config, args);
|
|
431
|
+
case "write_file":
|
|
432
|
+
return writeFile(config, args);
|
|
433
|
+
case "apply_patch":
|
|
434
|
+
return applyPatch(config, args);
|
|
435
|
+
default:
|
|
436
|
+
throw new Error(`Unknown workspace tool: ${toolName}`);
|
|
437
|
+
}
|
|
438
|
+
}
|