@causyne/agent 1.10.11 → 1.10.13
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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +46 -1
- package/package.json +2 -1
- package/src/agentLoop.js +136 -90
- package/src/benchmarks/planCache.js +159 -0
- package/src/commands.js +83 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +136 -0
- package/src/planCache.js +295 -14
- package/src/plugins/pluginManager.js +425 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/sandbox.js +21 -0
- package/src/taskShape.js +1 -1
- package/src/toolPacks.js +6 -3
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/toolForge.js +47 -95
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { causyneHome } from '../utils/paths.js';
|
|
7
|
+
|
|
8
|
+
const PLUGIN_ROOT = path.join(causyneHome(), 'plugins');
|
|
9
|
+
const STATE_FILE = path.join(PLUGIN_ROOT, 'state.json');
|
|
10
|
+
const RUNNER_FILE = fileURLToPath(new URL('./pluginRunner.js', import.meta.url));
|
|
11
|
+
const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
12
|
+
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_ENTRY_BYTES = 512 * 1024;
|
|
14
|
+
const MAX_PLUGIN_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const MAX_PLUGIN_FILES = 128;
|
|
16
|
+
const MAX_TOOLS_PER_PLUGIN = 32;
|
|
17
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
+
const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
21
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
22
|
+
const CAPABILITIES = new Set([
|
|
23
|
+
'workspace:read',
|
|
24
|
+
'workspace:write',
|
|
25
|
+
'network',
|
|
26
|
+
'subprocess'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function ensureRoot() {
|
|
30
|
+
if (!fs.existsSync(PLUGIN_ROOT)) fs.mkdirSync(PLUGIN_ROOT, { recursive: true, mode: 0o700 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeObject(value) {
|
|
34
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readState() {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(STATE_FILE)) return { version: 1, plugins: {} };
|
|
40
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
41
|
+
return { version: 1, plugins: safeObject(parsed.plugins) };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { version: 1, plugins: {} };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeState(state) {
|
|
48
|
+
ensureRoot();
|
|
49
|
+
const temporary = `${STATE_FILE}.tmp_${process.pid}`;
|
|
50
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
51
|
+
fs.renameSync(temporary, STATE_FILE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveInside(root, relativePath) {
|
|
55
|
+
const resolvedRoot = path.resolve(root);
|
|
56
|
+
const resolved = path.resolve(root, String(relativePath || ''));
|
|
57
|
+
if (resolved === resolvedRoot || !resolved.startsWith(resolvedRoot + path.sep)) {
|
|
58
|
+
throw new Error(`Path escapes plugin root: ${relativePath}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function regularFile(file, maxBytes) {
|
|
64
|
+
const stat = fs.lstatSync(file);
|
|
65
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
|
|
66
|
+
if (stat.size > maxBytes) throw new Error(`${file} exceeds ${Math.round(maxBytes / 1024)} KB`);
|
|
67
|
+
return stat;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function digestPluginDirectory(directory) {
|
|
71
|
+
const files = [];
|
|
72
|
+
const visit = (current) => {
|
|
73
|
+
const entries = fs.readdirSync(current, { withFileTypes: true })
|
|
74
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const absolute = path.join(current, entry.name);
|
|
77
|
+
const stat = fs.lstatSync(absolute);
|
|
78
|
+
if (stat.isSymbolicLink()) throw new Error(`Plugin content must not be a symlink: ${entry.name}`);
|
|
79
|
+
if (stat.isDirectory()) {
|
|
80
|
+
visit(absolute);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!stat.isFile()) throw new Error(`Plugin content must be a regular file: ${entry.name}`);
|
|
84
|
+
files.push({ absolute, relative: path.relative(directory, absolute), size: stat.size });
|
|
85
|
+
if (files.length > MAX_PLUGIN_FILES) throw new Error(`Plugin exceeds ${MAX_PLUGIN_FILES} files`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
visit(directory);
|
|
89
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
90
|
+
if (totalBytes > MAX_PLUGIN_BYTES) throw new Error('Plugin exceeds 2 MB');
|
|
91
|
+
|
|
92
|
+
const digest = crypto.createHash('sha256');
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
digest.update(file.relative);
|
|
95
|
+
digest.update('\0');
|
|
96
|
+
digest.update(fs.readFileSync(file.absolute));
|
|
97
|
+
digest.update('\0');
|
|
98
|
+
}
|
|
99
|
+
return digest.digest('hex');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateCapabilities(values, label) {
|
|
103
|
+
if (values == null) return [];
|
|
104
|
+
if (!Array.isArray(values)) throw new Error(`${label} capabilities must be an array`);
|
|
105
|
+
const unique = [];
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
if (!CAPABILITIES.has(value)) throw new Error(`${label} requests unknown capability ${JSON.stringify(value)}`);
|
|
108
|
+
if (!unique.includes(value)) unique.push(value);
|
|
109
|
+
}
|
|
110
|
+
if (unique.includes('workspace:write') && !unique.includes('workspace:read')) unique.unshift('workspace:read');
|
|
111
|
+
return unique;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readPlugin(directory) {
|
|
115
|
+
const directoryStat = fs.lstatSync(directory);
|
|
116
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
117
|
+
throw new Error('Plugin directory must not be a symlink');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const manifestFile = path.join(directory, 'plugin.json');
|
|
121
|
+
regularFile(manifestFile, MAX_MANIFEST_BYTES);
|
|
122
|
+
const raw = fs.readFileSync(manifestFile, 'utf8');
|
|
123
|
+
const manifest = JSON.parse(raw);
|
|
124
|
+
const folderName = path.basename(directory);
|
|
125
|
+
|
|
126
|
+
if (manifest.schemaVersion !== 1) throw new Error('schemaVersion must be 1');
|
|
127
|
+
if (!NAME.test(manifest.name || '') || manifest.name !== folderName) {
|
|
128
|
+
throw new Error('Plugin name must match its lowercase folder name');
|
|
129
|
+
}
|
|
130
|
+
if (!VERSION.test(manifest.version || '')) throw new Error('Plugin version must use SemVer');
|
|
131
|
+
if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
|
|
132
|
+
throw new Error('Plugin description is required');
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(manifest.tools)) throw new Error('Plugin tools must be an array');
|
|
135
|
+
if (manifest.tools.length > MAX_TOOLS_PER_PLUGIN) {
|
|
136
|
+
throw new Error(`Plugin exceeds ${MAX_TOOLS_PER_PLUGIN} tools`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pluginCapabilities = validateCapabilities(manifest.capabilities, manifest.name);
|
|
140
|
+
const names = new Set();
|
|
141
|
+
const tools = manifest.tools.map((tool) => {
|
|
142
|
+
if (!TOOL_NAME.test(tool?.name || '')) throw new Error(`Invalid tool name ${JSON.stringify(tool?.name)}`);
|
|
143
|
+
if (names.has(tool.name)) throw new Error(`Duplicate tool name ${tool.name}`);
|
|
144
|
+
names.add(tool.name);
|
|
145
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) {
|
|
146
|
+
throw new Error(`Tool ${tool.name} needs a description`);
|
|
147
|
+
}
|
|
148
|
+
if (!tool.parameters || tool.parameters.type !== 'object') {
|
|
149
|
+
throw new Error(`Tool ${tool.name} parameters must be an object JSON schema`);
|
|
150
|
+
}
|
|
151
|
+
const entry = resolveInside(directory, tool.entry);
|
|
152
|
+
regularFile(entry, MAX_ENTRY_BYTES);
|
|
153
|
+
const capabilities = validateCapabilities(tool.capabilities ?? pluginCapabilities, `${manifest.name}/${tool.name}`);
|
|
154
|
+
return {
|
|
155
|
+
localName: tool.name,
|
|
156
|
+
name: `plugin__${manifest.name.replace(/-/g, '_')}__${tool.name}`,
|
|
157
|
+
description: tool.description.trim().slice(0, 1000),
|
|
158
|
+
parameters: tool.parameters,
|
|
159
|
+
entry,
|
|
160
|
+
exportName: typeof tool.export === 'string' && tool.export ? tool.export : 'execute',
|
|
161
|
+
capabilities,
|
|
162
|
+
timeoutMs: Number.isInteger(tool.timeoutMs)
|
|
163
|
+
? Math.max(1000, Math.min(tool.timeoutMs, 120_000))
|
|
164
|
+
: DEFAULT_TIMEOUT_MS
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
directory,
|
|
170
|
+
manifestFile,
|
|
171
|
+
// Pin every byte the plugin subprocess may read, not only the manifest.
|
|
172
|
+
// Otherwise an entry/helper could be replaced after review without
|
|
173
|
+
// changing plugin.json and would inherit stale trust.
|
|
174
|
+
digest: digestPluginDirectory(directory),
|
|
175
|
+
manifest: { ...manifest, capabilities: pluginCapabilities },
|
|
176
|
+
tools
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pluginDirectories() {
|
|
181
|
+
ensureRoot();
|
|
182
|
+
return fs.readdirSync(PLUGIN_ROOT, { withFileTypes: true })
|
|
183
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
184
|
+
.map((entry) => path.join(PLUGIN_ROOT, entry.name))
|
|
185
|
+
.sort();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function listPlugins() {
|
|
189
|
+
const state = readState();
|
|
190
|
+
const rows = [];
|
|
191
|
+
for (const directory of pluginDirectories()) {
|
|
192
|
+
try {
|
|
193
|
+
const plugin = readPlugin(directory);
|
|
194
|
+
const trust = safeObject(state.plugins[plugin.manifest.name]);
|
|
195
|
+
const modified = Boolean(trust.enabled && trust.digest !== plugin.digest);
|
|
196
|
+
rows.push({
|
|
197
|
+
name: plugin.manifest.name,
|
|
198
|
+
version: plugin.manifest.version,
|
|
199
|
+
description: plugin.manifest.description,
|
|
200
|
+
enabled: Boolean(trust.enabled && !modified),
|
|
201
|
+
modified,
|
|
202
|
+
digest: plugin.digest,
|
|
203
|
+
capabilities: plugin.manifest.capabilities,
|
|
204
|
+
tools: plugin.tools.map((tool) => tool.name),
|
|
205
|
+
path: plugin.directory
|
|
206
|
+
});
|
|
207
|
+
} catch (error) {
|
|
208
|
+
rows.push({ name: path.basename(directory), enabled: false, invalid: true, error: error.message, path: directory });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return rows;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function enablePlugin(name) {
|
|
215
|
+
if (!NAME.test(name || '')) return { ok: false, error: 'Invalid plugin name' };
|
|
216
|
+
try {
|
|
217
|
+
const plugin = readPlugin(resolveInside(PLUGIN_ROOT, name));
|
|
218
|
+
const state = readState();
|
|
219
|
+
state.plugins[name] = { enabled: true, digest: plugin.digest, enabledAt: Date.now() };
|
|
220
|
+
writeState(state);
|
|
221
|
+
return { ok: true, name, digest: plugin.digest, capabilities: plugin.manifest.capabilities, tools: plugin.tools.map((tool) => tool.name) };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { ok: false, error: error.message };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function disablePlugin(name) {
|
|
228
|
+
const state = readState();
|
|
229
|
+
const current = safeObject(state.plugins[name]);
|
|
230
|
+
state.plugins[name] = { ...current, enabled: false, disabledAt: Date.now() };
|
|
231
|
+
writeState(state);
|
|
232
|
+
return { ok: true, name };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createPlugin({ name, description, toolName, toolDescription, parameters, code, capabilities = [] }) {
|
|
236
|
+
if (!NAME.test(name || '')) return { ok: false, error: 'Plugin name must use lowercase letters, digits, and hyphens' };
|
|
237
|
+
if (!TOOL_NAME.test(toolName || '')) return { ok: false, error: 'Tool name must start with a lowercase letter and use letters, digits, _ or -' };
|
|
238
|
+
if (typeof description !== 'string' || !description.trim()) return { ok: false, error: 'Plugin description is required' };
|
|
239
|
+
if (typeof toolDescription !== 'string' || !toolDescription.trim()) return { ok: false, error: 'Tool description is required' };
|
|
240
|
+
if (!parameters || parameters.type !== 'object') return { ok: false, error: 'Tool parameters must be an object JSON schema' };
|
|
241
|
+
if (typeof code !== 'string' || !code.trim()) return { ok: false, error: 'Plugin code is required' };
|
|
242
|
+
|
|
243
|
+
let safeCapabilities;
|
|
244
|
+
try {
|
|
245
|
+
safeCapabilities = validateCapabilities(capabilities, name);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { ok: false, error: error.message };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ensureRoot();
|
|
251
|
+
const directory = path.join(PLUGIN_ROOT, name);
|
|
252
|
+
if (fs.existsSync(directory)) return { ok: false, error: `Plugin ${name} already exists` };
|
|
253
|
+
|
|
254
|
+
const entryName = `${toolName}.js`;
|
|
255
|
+
const manifest = {
|
|
256
|
+
schemaVersion: 1,
|
|
257
|
+
name,
|
|
258
|
+
version: '0.1.0',
|
|
259
|
+
description: description.trim(),
|
|
260
|
+
capabilities: safeCapabilities,
|
|
261
|
+
tools: [{
|
|
262
|
+
name: toolName,
|
|
263
|
+
description: toolDescription.trim(),
|
|
264
|
+
parameters,
|
|
265
|
+
entry: entryName,
|
|
266
|
+
export: 'execute'
|
|
267
|
+
}]
|
|
268
|
+
};
|
|
269
|
+
const trimmedCode = code.trim();
|
|
270
|
+
const source = trimmedCode.startsWith('export ')
|
|
271
|
+
? trimmedCode
|
|
272
|
+
: trimmedCode.startsWith('async ') || trimmedCode.startsWith('(async ')
|
|
273
|
+
? `export const execute = ${trimmedCode};\n`
|
|
274
|
+
: `export async function execute(args, context) {\n${trimmedCode}\n}\n`;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
|
|
278
|
+
fs.writeFileSync(path.join(directory, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
279
|
+
fs.writeFileSync(path.join(directory, entryName), source, { mode: 0o600 });
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
name,
|
|
283
|
+
path: directory,
|
|
284
|
+
enabled: false,
|
|
285
|
+
message: 'Plugin created disabled. Review its manifest and source, then enable it explicitly.'
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { ok: false, error: error.message };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function enabledTools() {
|
|
293
|
+
const state = readState();
|
|
294
|
+
const tools = [];
|
|
295
|
+
for (const directory of pluginDirectories()) {
|
|
296
|
+
try {
|
|
297
|
+
const plugin = readPlugin(directory);
|
|
298
|
+
const trust = safeObject(state.plugins[plugin.manifest.name]);
|
|
299
|
+
if (!trust.enabled || trust.digest !== plugin.digest) continue;
|
|
300
|
+
for (const tool of plugin.tools) tools.push({ ...tool, plugin: plugin.manifest.name, pluginDirectory: directory });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// Invalid plugins are visible through listPlugins(), never through the model.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return tools;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function getPluginToolDefinitions() {
|
|
309
|
+
return enabledTools().map((tool) => ({
|
|
310
|
+
name: tool.name,
|
|
311
|
+
description: tool.description,
|
|
312
|
+
parameters: tool.parameters,
|
|
313
|
+
xCausyneDeferred: true
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function permissionArgs(tool, workspaceRoot) {
|
|
318
|
+
// Node resolves `.js` module type by walking to package.json. Grant that one
|
|
319
|
+
// metadata file explicitly; granting the repository root would let an
|
|
320
|
+
// extension read Causyne's entire source tree for no functional reason.
|
|
321
|
+
const readable = [path.dirname(RUNNER_FILE), PACKAGE_FILE, tool.pluginDirectory];
|
|
322
|
+
const writable = [];
|
|
323
|
+
if (tool.capabilities.includes('workspace:read') || tool.capabilities.includes('workspace:write')) {
|
|
324
|
+
readable.push(workspaceRoot);
|
|
325
|
+
}
|
|
326
|
+
if (tool.capabilities.includes('workspace:write')) writable.push(workspaceRoot);
|
|
327
|
+
|
|
328
|
+
// Repeat the flag. Comma-joining paths is parsed as one literal path by
|
|
329
|
+
// Node's permission model on current releases.
|
|
330
|
+
const args = ['--permission', ...readable.map((entry) => `--allow-fs-read=${entry}`)];
|
|
331
|
+
if (writable.length > 0) {
|
|
332
|
+
args.push(...writable.map((entry) => `--allow-fs-write=${entry}`));
|
|
333
|
+
}
|
|
334
|
+
if (tool.capabilities.includes('network')) args.push('--allow-net');
|
|
335
|
+
if (tool.capabilities.includes('subprocess')) args.push('--allow-child-process');
|
|
336
|
+
return args;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function runPluginTool(tool, args, context) {
|
|
340
|
+
const workspaceRoot = path.resolve(context?.workspaceRoot || context?.workspacePaths?.[0] || process.cwd());
|
|
341
|
+
// Resolve filesystem aliases before handing paths to Node's permission
|
|
342
|
+
// model. On macOS, /var and /tmp are symlinks into /private; authorizing the
|
|
343
|
+
// alias while the module loader opens the canonical target causes a false
|
|
344
|
+
// denial even though both names identify the same reviewed plugin file.
|
|
345
|
+
const runtimeTool = {
|
|
346
|
+
...tool,
|
|
347
|
+
entry: fs.realpathSync(tool.entry),
|
|
348
|
+
pluginDirectory: fs.realpathSync(tool.pluginDirectory)
|
|
349
|
+
};
|
|
350
|
+
const childArgs = [
|
|
351
|
+
...permissionArgs(runtimeTool, workspaceRoot),
|
|
352
|
+
RUNNER_FILE,
|
|
353
|
+
runtimeTool.entry,
|
|
354
|
+
runtimeTool.exportName
|
|
355
|
+
];
|
|
356
|
+
const payload = JSON.stringify({
|
|
357
|
+
args: safeObject(args),
|
|
358
|
+
context: {
|
|
359
|
+
workspaceRoot,
|
|
360
|
+
pluginRoot: runtimeTool.pluginDirectory,
|
|
361
|
+
capabilities: runtimeTool.capabilities
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
const child = spawn(process.execPath, childArgs, {
|
|
367
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
368
|
+
env: {
|
|
369
|
+
PATH: process.env.PATH || '',
|
|
370
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
371
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
let stdout = '';
|
|
375
|
+
let stderr = '';
|
|
376
|
+
let settled = false;
|
|
377
|
+
const finish = (value) => {
|
|
378
|
+
if (settled) return;
|
|
379
|
+
settled = true;
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
resolve(value);
|
|
382
|
+
};
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
child.kill('SIGTERM');
|
|
385
|
+
finish({ success: false, error: `Plugin timed out after ${runtimeTool.timeoutMs} ms` });
|
|
386
|
+
}, runtimeTool.timeoutMs);
|
|
387
|
+
if (timer.unref) timer.unref();
|
|
388
|
+
|
|
389
|
+
child.stdout.on('data', (chunk) => {
|
|
390
|
+
stdout += chunk;
|
|
391
|
+
if (Buffer.byteLength(stdout, 'utf8') > MAX_RESULT_BYTES) {
|
|
392
|
+
child.kill('SIGTERM');
|
|
393
|
+
finish({ success: false, error: 'Plugin result exceeded 1 MB' });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
child.stderr.on('data', (chunk) => {
|
|
397
|
+
stderr = (stderr + chunk).slice(-16_000);
|
|
398
|
+
});
|
|
399
|
+
child.on('error', (error) => finish({ success: false, error: error.message }));
|
|
400
|
+
child.on('close', () => {
|
|
401
|
+
const line = stdout.split(/\r?\n/).find((value) => value.startsWith('CAUSYNE_PLUGIN_RESULT:'));
|
|
402
|
+
if (!line) {
|
|
403
|
+
finish({ success: false, error: stderr.trim() || 'Plugin returned no structured result' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
finish(JSON.parse(line.slice('CAUSYNE_PLUGIN_RESULT:'.length)));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
finish({ success: false, error: `Plugin returned invalid JSON: ${error.message}` });
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
child.stdin.end(payload);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function resolvePluginTool(name) {
|
|
417
|
+
const tool = enabledTools().find((candidate) => candidate.name === name);
|
|
418
|
+
if (!tool) return null;
|
|
419
|
+
return {
|
|
420
|
+
schema: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
421
|
+
execute: (args, context = {}) => runPluginTool(tool, args, context)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export { PLUGIN_ROOT, STATE_FILE, CAPABILITIES };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
|
|
3
|
+
const MAX_INPUT_BYTES = 1024 * 1024;
|
|
4
|
+
const RESULT_PREFIX = 'CAUSYNE_PLUGIN_RESULT:';
|
|
5
|
+
|
|
6
|
+
function writeResult(payload) {
|
|
7
|
+
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(payload)}\n`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function readInput() {
|
|
11
|
+
let input = '';
|
|
12
|
+
for await (const chunk of process.stdin) {
|
|
13
|
+
input += chunk;
|
|
14
|
+
if (Buffer.byteLength(input, 'utf8') > MAX_INPUT_BYTES) {
|
|
15
|
+
throw new Error('Plugin input exceeds 1 MB');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return JSON.parse(input || '{}');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const entry = process.argv[2];
|
|
23
|
+
const exportName = process.argv[3] || 'execute';
|
|
24
|
+
if (!entry) throw new Error('Plugin entry path is required');
|
|
25
|
+
|
|
26
|
+
const payload = await readInput();
|
|
27
|
+
const imported = await import(pathToFileURL(entry).href);
|
|
28
|
+
const execute = imported[exportName];
|
|
29
|
+
if (typeof execute !== 'function') {
|
|
30
|
+
throw new Error(`Plugin export ${JSON.stringify(exportName)} is not a function`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await execute(payload.args || {}, payload.context || {});
|
|
34
|
+
writeResult(result && typeof result === 'object'
|
|
35
|
+
? result
|
|
36
|
+
: { success: true, output: result });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log = (...values) => process.stderr.write(`${values.map(String).join(' ')}\n`);
|
|
40
|
+
console.info = console.log;
|
|
41
|
+
console.warn = console.log;
|
|
42
|
+
console.error = console.log;
|
|
43
|
+
|
|
44
|
+
main().catch((error) => {
|
|
45
|
+
const details = [error?.message || String(error)];
|
|
46
|
+
if (error?.permission) details.push(`permission=${error.permission}`);
|
|
47
|
+
if (error?.resource) details.push(`resource=${error.resource}`);
|
|
48
|
+
writeResult({ success: false, error: details.join(' ') });
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
});
|
package/src/sandbox.js
CHANGED
|
@@ -183,6 +183,10 @@ export class Sandbox {
|
|
|
183
183
|
const devMode = isDevEmulator(this.config);
|
|
184
184
|
this.grantAllAccess = devMode ? true : (this.config.grantAllAccess || false);
|
|
185
185
|
this.allowedTools = devMode ? ['*'] : (this.config.allowedTools || ['*']);
|
|
186
|
+
this.deniedTools = new Set(
|
|
187
|
+
(devMode ? [] : (this.config.deniedTools || []))
|
|
188
|
+
.map((name) => String(name).replace(/\./g, '_'))
|
|
189
|
+
);
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
|
|
@@ -214,6 +218,9 @@ export class Sandbox {
|
|
|
214
218
|
|
|
215
219
|
validate(toolName, args) {
|
|
216
220
|
// 1. Tool availability check
|
|
221
|
+
if (this.deniedTools.has(String(toolName).replace(/\./g, '_'))) {
|
|
222
|
+
return { allowed: false, reason: `Tool ${toolName} is denied by local policy`, risk: 'forbidden' };
|
|
223
|
+
}
|
|
217
224
|
if (!this.allowedTools.includes('*') && !this.allowedTools.includes(toolName)) {
|
|
218
225
|
return { allowed: false, reason: `Tool ${toolName} not allowed on current tier`, risk: 'forbidden' };
|
|
219
226
|
}
|
|
@@ -288,6 +295,20 @@ export class Sandbox {
|
|
|
288
295
|
}
|
|
289
296
|
return { allowed: true, risk: 'moderate' };
|
|
290
297
|
}
|
|
298
|
+
|
|
299
|
+
// Plugins are code extensions. Creation and enablement change the future
|
|
300
|
+
// executable surface, and an enabled plugin call crosses from Causyne's
|
|
301
|
+
// reviewed code into user/third-party code. Keep both explicit and
|
|
302
|
+
// session-revocable even though the plugin process also runs under Node's
|
|
303
|
+
// filesystem/network/child-process permission model.
|
|
304
|
+
if (toolName === 'plugin_manager') {
|
|
305
|
+
return ['create', 'enable'].includes(args.action)
|
|
306
|
+
? { allowed: true, risk: 'extreme', reason: 'Creates or trusts executable plugin code' }
|
|
307
|
+
: { allowed: true, risk: 'safe' };
|
|
308
|
+
}
|
|
309
|
+
if (toolName.startsWith('plugin__')) {
|
|
310
|
+
return { allowed: true, risk: 'extreme', reason: 'Executes an enabled local plugin' };
|
|
311
|
+
}
|
|
291
312
|
|
|
292
313
|
// Tools that overwrite or delete are inherently dangerous.
|
|
293
314
|
// These comparisons used the dotted schema names (`fs.delete`, `proc.term`,
|
package/src/taskShape.js
CHANGED
|
@@ -18,7 +18,7 @@ const PACK_SIGNALS = {
|
|
|
18
18
|
weak: ['process', 'proses', 'compress', 'extract', 'ekstrak', 'open app', 'launch']
|
|
19
19
|
},
|
|
20
20
|
advanced: {
|
|
21
|
-
strong: ['pdf', 'forge a tool', 'custom tool', 'subagent', 'sub-agent', 'parallel agent'],
|
|
21
|
+
strong: ['pdf', 'forge a tool', 'custom tool', 'plugin', 'plugins', 'custom plugin', 'subagent', 'sub-agent', 'parallel agent'],
|
|
22
22
|
weak: ['symbols', 'ast', 'refactor', 'orchestrate', 'swarm']
|
|
23
23
|
}
|
|
24
24
|
};
|
package/src/toolPacks.js
CHANGED
|
@@ -106,6 +106,7 @@ export const PACKS = {
|
|
|
106
106
|
],
|
|
107
107
|
advanced: [
|
|
108
108
|
'tool_forge',
|
|
109
|
+
'plugin_manager',
|
|
109
110
|
'agent_subagent_orchestrate',
|
|
110
111
|
'code_search_ast',
|
|
111
112
|
'code_list_symbols',
|
|
@@ -364,7 +365,7 @@ const MEDIA_INTENT = /\b(image|images|picture|photo|gambar|render|3d model|blend
|
|
|
364
365
|
|
|
365
366
|
const SYSTEM_INTENT = /\b(process|processes|pid|kill|port|zip|unzip|tar|archive|compress|extract|clipboard|copy to clipboard|print|printer|cetak|open in browser|launch app)\b/i;
|
|
366
367
|
|
|
367
|
-
const ADVANCED_INTENT = /\b(pdf|forge a tool|custom tool|new tool|subagent|sub-agent|parallel agent|swarm|ast|symbols|refactor across)\b/i;
|
|
368
|
+
const ADVANCED_INTENT = /\b(pdf|forge a tool|custom tool|new tool|plugin|plugins|custom plugin|subagent|sub-agent|parallel agent|swarm|ast|symbols|refactor across)\b/i;
|
|
368
369
|
|
|
369
370
|
const INTENT_MATCHERS = {
|
|
370
371
|
game: GAME_INTENT,
|
|
@@ -457,9 +458,11 @@ export function resolveActiveTools(allDefinitions, unlockedPacks, unlockedTools,
|
|
|
457
458
|
return isResidentTool(def.name) || tools.has(def.name);
|
|
458
459
|
}
|
|
459
460
|
if (tools.has(def.name)) return true;
|
|
461
|
+
if (def.xCausyneDeferred === true) return false;
|
|
460
462
|
const memberOf = packsForTool(def.name);
|
|
461
|
-
// A tool with no declared pack
|
|
462
|
-
//
|
|
463
|
+
// A native tool with no declared pack stays available. Manifest plugins are
|
|
464
|
+
// marked xCausyneDeferred and enter through tool_search, so enabling many
|
|
465
|
+
// plugins does not permanently spend their schemas on unrelated tasks.
|
|
463
466
|
if (!memberOf) return true;
|
|
464
467
|
for (const pack of memberOf) {
|
|
465
468
|
if (packs.has(pack)) return true;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// registry.js was always imported first. Loaded on demand instead — execute()
|
|
5
5
|
// is async, so this costs nothing.
|
|
6
6
|
async function toolDefinitions() {
|
|
7
|
-
const {
|
|
8
|
-
return
|
|
7
|
+
const { getSearchableToolDefinitions } = await import('./registry.js');
|
|
8
|
+
return getSearchableToolDefinitions();
|
|
9
9
|
}
|
|
10
10
|
import { PACKS, PACK_NAMES, packForTool, isCoreTool, isResidentTool } from '../toolPacks.js';
|
|
11
11
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPlugin,
|
|
3
|
+
disablePlugin,
|
|
4
|
+
enablePlugin,
|
|
5
|
+
listPlugins
|
|
6
|
+
} from '../plugins/pluginManager.js';
|
|
7
|
+
|
|
8
|
+
export const pluginManagerTool = {
|
|
9
|
+
schema: {
|
|
10
|
+
name: 'plugin.manager',
|
|
11
|
+
description: 'Create and manage local Causyne plugins. Plugins package custom tools and run only after explicit enablement. Use list to inspect plugins; create writes a disabled plugin; enable trusts the current manifest digest; disable revokes it.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
action: { type: 'string', enum: ['list', 'create', 'enable', 'disable'] },
|
|
16
|
+
name: { type: 'string', description: 'Lowercase plugin name using letters, digits, and hyphens.' },
|
|
17
|
+
description: { type: 'string' },
|
|
18
|
+
toolName: { type: 'string' },
|
|
19
|
+
toolDescription: { type: 'string' },
|
|
20
|
+
parametersSchema: { type: 'string', description: 'JSON object schema for the custom tool arguments.' },
|
|
21
|
+
javascriptCode: { type: 'string', description: 'Body of async execute(args, context), or a complete module exporting execute.' },
|
|
22
|
+
capabilities: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
items: { type: 'string', enum: ['workspace:read', 'workspace:write', 'network', 'subprocess'] }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
required: ['action']
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async execute(args) {
|
|
32
|
+
if (args.action === 'list') return { success: true, plugins: listPlugins() };
|
|
33
|
+
if (args.action === 'enable') {
|
|
34
|
+
const result = enablePlugin(args.name);
|
|
35
|
+
return result.ok ? { success: true, ...result } : { success: false, error: result.error };
|
|
36
|
+
}
|
|
37
|
+
if (args.action === 'disable') {
|
|
38
|
+
const result = disablePlugin(args.name);
|
|
39
|
+
return result.ok ? { success: true, ...result } : { success: false, error: result.error };
|
|
40
|
+
}
|
|
41
|
+
if (args.action === 'create') {
|
|
42
|
+
let parameters;
|
|
43
|
+
try {
|
|
44
|
+
parameters = JSON.parse(args.parametersSchema || '{}');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { success: false, error: `parametersSchema is invalid JSON: ${error.message}` };
|
|
47
|
+
}
|
|
48
|
+
const result = createPlugin({
|
|
49
|
+
name: args.name,
|
|
50
|
+
description: args.description,
|
|
51
|
+
toolName: args.toolName,
|
|
52
|
+
toolDescription: args.toolDescription,
|
|
53
|
+
parameters,
|
|
54
|
+
code: args.javascriptCode,
|
|
55
|
+
capabilities: args.capabilities || []
|
|
56
|
+
});
|
|
57
|
+
return result.ok ? { success: true, ...result } : { success: false, error: result.error };
|
|
58
|
+
}
|
|
59
|
+
return { success: false, error: `Unknown plugin action: ${args.action}` };
|
|
60
|
+
}
|
|
61
|
+
};
|