@causyne/agent 1.10.12 → 1.10.17
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 +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/benchmarks/planCache.js +159 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +53 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
|
@@ -0,0 +1,460 @@
|
|
|
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
|
+
import { parsePluginSkills } from './pluginSkills.js';
|
|
8
|
+
|
|
9
|
+
const PLUGIN_ROOT = path.join(causyneHome(), 'plugins');
|
|
10
|
+
const STATE_FILE = path.join(PLUGIN_ROOT, 'state.json');
|
|
11
|
+
const RUNNER_FILE = fileURLToPath(new URL('./pluginRunner.js', import.meta.url));
|
|
12
|
+
const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
13
|
+
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_ENTRY_BYTES = 512 * 1024;
|
|
15
|
+
const MAX_PLUGIN_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const MAX_PLUGIN_FILES = 128;
|
|
17
|
+
const MAX_TOOLS_PER_PLUGIN = 32;
|
|
18
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
21
|
+
const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
22
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
23
|
+
const CAPABILITIES = new Set([
|
|
24
|
+
'workspace:read',
|
|
25
|
+
'workspace:write',
|
|
26
|
+
'network',
|
|
27
|
+
'subprocess'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function ensureRoot() {
|
|
31
|
+
if (!fs.existsSync(PLUGIN_ROOT)) fs.mkdirSync(PLUGIN_ROOT, { recursive: true, mode: 0o700 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeObject(value) {
|
|
35
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readState() {
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(STATE_FILE)) return { version: 1, plugins: {} };
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
42
|
+
return { version: 1, plugins: safeObject(parsed.plugins) };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { version: 1, plugins: {} };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeState(state) {
|
|
49
|
+
ensureRoot();
|
|
50
|
+
const temporary = `${STATE_FILE}.tmp_${process.pid}`;
|
|
51
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
52
|
+
fs.renameSync(temporary, STATE_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveInside(root, relativePath) {
|
|
56
|
+
const resolvedRoot = path.resolve(root);
|
|
57
|
+
const resolved = path.resolve(root, String(relativePath || ''));
|
|
58
|
+
if (resolved === resolvedRoot || !resolved.startsWith(resolvedRoot + path.sep)) {
|
|
59
|
+
throw new Error(`Path escapes plugin root: ${relativePath}`);
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function regularFile(file, maxBytes) {
|
|
65
|
+
const stat = fs.lstatSync(file);
|
|
66
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
|
|
67
|
+
if (stat.size > maxBytes) throw new Error(`${file} exceeds ${Math.round(maxBytes / 1024)} KB`);
|
|
68
|
+
return stat;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function digestPluginDirectory(directory) {
|
|
72
|
+
const files = [];
|
|
73
|
+
const visit = (current) => {
|
|
74
|
+
const entries = fs.readdirSync(current, { withFileTypes: true })
|
|
75
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const absolute = path.join(current, entry.name);
|
|
78
|
+
const stat = fs.lstatSync(absolute);
|
|
79
|
+
if (stat.isSymbolicLink()) throw new Error(`Plugin content must not be a symlink: ${entry.name}`);
|
|
80
|
+
if (stat.isDirectory()) {
|
|
81
|
+
visit(absolute);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!stat.isFile()) throw new Error(`Plugin content must be a regular file: ${entry.name}`);
|
|
85
|
+
files.push({ absolute, relative: path.relative(directory, absolute), size: stat.size });
|
|
86
|
+
if (files.length > MAX_PLUGIN_FILES) throw new Error(`Plugin exceeds ${MAX_PLUGIN_FILES} files`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
visit(directory);
|
|
90
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
91
|
+
if (totalBytes > MAX_PLUGIN_BYTES) throw new Error('Plugin exceeds 2 MB');
|
|
92
|
+
|
|
93
|
+
const digest = crypto.createHash('sha256');
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
digest.update(file.relative);
|
|
96
|
+
digest.update('\0');
|
|
97
|
+
digest.update(fs.readFileSync(file.absolute));
|
|
98
|
+
digest.update('\0');
|
|
99
|
+
}
|
|
100
|
+
return digest.digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateCapabilities(values, label) {
|
|
104
|
+
if (values == null) return [];
|
|
105
|
+
if (!Array.isArray(values)) throw new Error(`${label} capabilities must be an array`);
|
|
106
|
+
const unique = [];
|
|
107
|
+
for (const value of values) {
|
|
108
|
+
if (!CAPABILITIES.has(value)) throw new Error(`${label} requests unknown capability ${JSON.stringify(value)}`);
|
|
109
|
+
if (!unique.includes(value)) unique.push(value);
|
|
110
|
+
}
|
|
111
|
+
if (unique.includes('workspace:write') && !unique.includes('workspace:read')) unique.unshift('workspace:read');
|
|
112
|
+
return unique;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readPlugin(directory) {
|
|
116
|
+
const directoryStat = fs.lstatSync(directory);
|
|
117
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
118
|
+
throw new Error('Plugin directory must not be a symlink');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const manifestFile = path.join(directory, 'plugin.json');
|
|
122
|
+
regularFile(manifestFile, MAX_MANIFEST_BYTES);
|
|
123
|
+
const raw = fs.readFileSync(manifestFile, 'utf8');
|
|
124
|
+
const manifest = JSON.parse(raw);
|
|
125
|
+
const folderName = path.basename(directory);
|
|
126
|
+
|
|
127
|
+
if (manifest.schemaVersion !== 1) throw new Error('schemaVersion must be 1');
|
|
128
|
+
if (!NAME.test(manifest.name || '') || manifest.name !== folderName) {
|
|
129
|
+
throw new Error('Plugin name must match its lowercase folder name');
|
|
130
|
+
}
|
|
131
|
+
if (!VERSION.test(manifest.version || '')) throw new Error('Plugin version must use SemVer');
|
|
132
|
+
if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
|
|
133
|
+
throw new Error('Plugin description is required');
|
|
134
|
+
}
|
|
135
|
+
const manifestTools = manifest.tools ?? [];
|
|
136
|
+
if (!Array.isArray(manifestTools)) throw new Error('Plugin tools must be an array');
|
|
137
|
+
if (manifestTools.length > MAX_TOOLS_PER_PLUGIN) {
|
|
138
|
+
throw new Error(`Plugin exceeds ${MAX_TOOLS_PER_PLUGIN} tools`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pluginCapabilities = validateCapabilities(manifest.capabilities, manifest.name);
|
|
142
|
+
const names = new Set();
|
|
143
|
+
const tools = manifestTools.map((tool) => {
|
|
144
|
+
if (!TOOL_NAME.test(tool?.name || '')) throw new Error(`Invalid tool name ${JSON.stringify(tool?.name)}`);
|
|
145
|
+
if (names.has(tool.name)) throw new Error(`Duplicate tool name ${tool.name}`);
|
|
146
|
+
names.add(tool.name);
|
|
147
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) {
|
|
148
|
+
throw new Error(`Tool ${tool.name} needs a description`);
|
|
149
|
+
}
|
|
150
|
+
if (!tool.parameters || tool.parameters.type !== 'object') {
|
|
151
|
+
throw new Error(`Tool ${tool.name} parameters must be an object JSON schema`);
|
|
152
|
+
}
|
|
153
|
+
const entry = resolveInside(directory, tool.entry);
|
|
154
|
+
regularFile(entry, MAX_ENTRY_BYTES);
|
|
155
|
+
const capabilities = validateCapabilities(tool.capabilities ?? pluginCapabilities, `${manifest.name}/${tool.name}`);
|
|
156
|
+
return {
|
|
157
|
+
localName: tool.name,
|
|
158
|
+
name: `plugin__${manifest.name.replace(/-/g, '_')}__${tool.name}`,
|
|
159
|
+
description: tool.description.trim().slice(0, 1000),
|
|
160
|
+
parameters: tool.parameters,
|
|
161
|
+
entry,
|
|
162
|
+
exportName: typeof tool.export === 'string' && tool.export ? tool.export : 'execute',
|
|
163
|
+
capabilities,
|
|
164
|
+
timeoutMs: Number.isInteger(tool.timeoutMs)
|
|
165
|
+
? Math.max(1000, Math.min(tool.timeoutMs, 120_000))
|
|
166
|
+
: DEFAULT_TIMEOUT_MS
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const skills = parsePluginSkills(directory, manifest.name, manifest.skills);
|
|
170
|
+
if (tools.length === 0 && skills.length === 0) {
|
|
171
|
+
throw new Error('Plugin must contribute at least one tool or skill');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
directory,
|
|
176
|
+
manifestFile,
|
|
177
|
+
// Pin every byte the plugin subprocess may read, not only the manifest.
|
|
178
|
+
// Otherwise an entry/helper could be replaced after review without
|
|
179
|
+
// changing plugin.json and would inherit stale trust.
|
|
180
|
+
digest: digestPluginDirectory(directory),
|
|
181
|
+
manifest: { ...manifest, capabilities: pluginCapabilities },
|
|
182
|
+
tools,
|
|
183
|
+
skills
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pluginDirectories() {
|
|
188
|
+
ensureRoot();
|
|
189
|
+
return fs.readdirSync(PLUGIN_ROOT, { withFileTypes: true })
|
|
190
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
191
|
+
.map((entry) => path.join(PLUGIN_ROOT, entry.name))
|
|
192
|
+
.sort();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function listPlugins() {
|
|
196
|
+
const state = readState();
|
|
197
|
+
const rows = [];
|
|
198
|
+
for (const directory of pluginDirectories()) {
|
|
199
|
+
try {
|
|
200
|
+
const plugin = readPlugin(directory);
|
|
201
|
+
const trust = safeObject(state.plugins[plugin.manifest.name]);
|
|
202
|
+
const modified = Boolean(trust.enabled && trust.digest !== plugin.digest);
|
|
203
|
+
rows.push({
|
|
204
|
+
name: plugin.manifest.name,
|
|
205
|
+
version: plugin.manifest.version,
|
|
206
|
+
description: plugin.manifest.description,
|
|
207
|
+
enabled: Boolean(trust.enabled && !modified),
|
|
208
|
+
modified,
|
|
209
|
+
digest: plugin.digest,
|
|
210
|
+
capabilities: plugin.manifest.capabilities,
|
|
211
|
+
tools: plugin.tools.map((tool) => tool.name),
|
|
212
|
+
skills: plugin.skills.map((skill) => `${skill.domain}/${skill.id}`),
|
|
213
|
+
path: plugin.directory
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
rows.push({ name: path.basename(directory), enabled: false, invalid: true, error: error.message, path: directory });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return rows;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function enablePlugin(name) {
|
|
223
|
+
if (!NAME.test(name || '')) return { ok: false, error: 'Invalid plugin name' };
|
|
224
|
+
try {
|
|
225
|
+
const plugin = readPlugin(resolveInside(PLUGIN_ROOT, name));
|
|
226
|
+
const state = readState();
|
|
227
|
+
state.plugins[name] = { enabled: true, digest: plugin.digest, enabledAt: Date.now() };
|
|
228
|
+
writeState(state);
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
name,
|
|
232
|
+
digest: plugin.digest,
|
|
233
|
+
capabilities: plugin.manifest.capabilities,
|
|
234
|
+
tools: plugin.tools.map((tool) => tool.name),
|
|
235
|
+
skills: plugin.skills.map((skill) => `${skill.domain}/${skill.id}`)
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { ok: false, error: error.message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function disablePlugin(name) {
|
|
243
|
+
const state = readState();
|
|
244
|
+
const current = safeObject(state.plugins[name]);
|
|
245
|
+
state.plugins[name] = { ...current, enabled: false, disabledAt: Date.now() };
|
|
246
|
+
writeState(state);
|
|
247
|
+
return { ok: true, name };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function createPlugin({ name, description, toolName, toolDescription, parameters, code, capabilities = [] }) {
|
|
251
|
+
if (!NAME.test(name || '')) return { ok: false, error: 'Plugin name must use lowercase letters, digits, and hyphens' };
|
|
252
|
+
if (!TOOL_NAME.test(toolName || '')) return { ok: false, error: 'Tool name must start with a lowercase letter and use letters, digits, _ or -' };
|
|
253
|
+
if (typeof description !== 'string' || !description.trim()) return { ok: false, error: 'Plugin description is required' };
|
|
254
|
+
if (typeof toolDescription !== 'string' || !toolDescription.trim()) return { ok: false, error: 'Tool description is required' };
|
|
255
|
+
if (!parameters || parameters.type !== 'object') return { ok: false, error: 'Tool parameters must be an object JSON schema' };
|
|
256
|
+
if (typeof code !== 'string' || !code.trim()) return { ok: false, error: 'Plugin code is required' };
|
|
257
|
+
|
|
258
|
+
let safeCapabilities;
|
|
259
|
+
try {
|
|
260
|
+
safeCapabilities = validateCapabilities(capabilities, name);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return { ok: false, error: error.message };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
ensureRoot();
|
|
266
|
+
const directory = path.join(PLUGIN_ROOT, name);
|
|
267
|
+
if (fs.existsSync(directory)) return { ok: false, error: `Plugin ${name} already exists` };
|
|
268
|
+
|
|
269
|
+
const entryName = `${toolName}.js`;
|
|
270
|
+
const manifest = {
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
name,
|
|
273
|
+
version: '0.1.0',
|
|
274
|
+
description: description.trim(),
|
|
275
|
+
capabilities: safeCapabilities,
|
|
276
|
+
tools: [{
|
|
277
|
+
name: toolName,
|
|
278
|
+
description: toolDescription.trim(),
|
|
279
|
+
parameters,
|
|
280
|
+
entry: entryName,
|
|
281
|
+
export: 'execute'
|
|
282
|
+
}]
|
|
283
|
+
};
|
|
284
|
+
const trimmedCode = code.trim();
|
|
285
|
+
const source = trimmedCode.startsWith('export ')
|
|
286
|
+
? trimmedCode
|
|
287
|
+
: trimmedCode.startsWith('async ') || trimmedCode.startsWith('(async ')
|
|
288
|
+
? `export const execute = ${trimmedCode};\n`
|
|
289
|
+
: `export async function execute(args, context) {\n${trimmedCode}\n}\n`;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
|
|
293
|
+
fs.writeFileSync(path.join(directory, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
294
|
+
fs.writeFileSync(path.join(directory, entryName), source, { mode: 0o600 });
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
name,
|
|
298
|
+
path: directory,
|
|
299
|
+
enabled: false,
|
|
300
|
+
message: 'Plugin created disabled. Review its manifest and source, then enable it explicitly.'
|
|
301
|
+
};
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return { ok: false, error: error.message };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function enabledTools() {
|
|
308
|
+
const state = readState();
|
|
309
|
+
const tools = [];
|
|
310
|
+
for (const directory of pluginDirectories()) {
|
|
311
|
+
try {
|
|
312
|
+
const plugin = readPlugin(directory);
|
|
313
|
+
const trust = safeObject(state.plugins[plugin.manifest.name]);
|
|
314
|
+
if (!trust.enabled || trust.digest !== plugin.digest) continue;
|
|
315
|
+
for (const tool of plugin.tools) tools.push({ ...tool, plugin: plugin.manifest.name, pluginDirectory: directory });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
// Invalid plugins are visible through listPlugins(), never through the model.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return tools;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getPluginToolDefinitions() {
|
|
324
|
+
return enabledTools().map((tool) => ({
|
|
325
|
+
name: tool.name,
|
|
326
|
+
description: tool.description,
|
|
327
|
+
parameters: tool.parameters,
|
|
328
|
+
xCausyneDeferred: true
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reviewed, enabled plugins may contribute prompt-only skills. Discovery has
|
|
334
|
+
* already parsed these as inert UTF-8 data; no plugin module is imported.
|
|
335
|
+
*/
|
|
336
|
+
export function getPluginSkills() {
|
|
337
|
+
const state = readState();
|
|
338
|
+
const skills = [];
|
|
339
|
+
for (const directory of pluginDirectories()) {
|
|
340
|
+
try {
|
|
341
|
+
const plugin = readPlugin(directory);
|
|
342
|
+
const trust = safeObject(state.plugins[plugin.manifest.name]);
|
|
343
|
+
if (!trust.enabled || trust.digest !== plugin.digest) continue;
|
|
344
|
+
skills.push(...plugin.skills);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
// Invalid or modified plugins contribute nothing to the prompt.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return skills;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function permissionArgs(tool, workspaceRoot) {
|
|
353
|
+
// Node resolves `.js` module type by walking to package.json. Grant that one
|
|
354
|
+
// metadata file explicitly; granting the repository root would let an
|
|
355
|
+
// extension read Causyne's entire source tree for no functional reason.
|
|
356
|
+
const readable = [path.dirname(RUNNER_FILE), PACKAGE_FILE, tool.pluginDirectory];
|
|
357
|
+
const writable = [];
|
|
358
|
+
if (tool.capabilities.includes('workspace:read') || tool.capabilities.includes('workspace:write')) {
|
|
359
|
+
readable.push(workspaceRoot);
|
|
360
|
+
}
|
|
361
|
+
if (tool.capabilities.includes('workspace:write')) writable.push(workspaceRoot);
|
|
362
|
+
|
|
363
|
+
// Repeat the flag. Comma-joining paths is parsed as one literal path by
|
|
364
|
+
// Node's permission model on current releases.
|
|
365
|
+
const args = ['--permission', ...readable.map((entry) => `--allow-fs-read=${entry}`)];
|
|
366
|
+
if (writable.length > 0) {
|
|
367
|
+
args.push(...writable.map((entry) => `--allow-fs-write=${entry}`));
|
|
368
|
+
}
|
|
369
|
+
if (tool.capabilities.includes('network')) args.push('--allow-net');
|
|
370
|
+
if (tool.capabilities.includes('subprocess')) args.push('--allow-child-process');
|
|
371
|
+
return args;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function runPluginTool(tool, args, context) {
|
|
375
|
+
const workspaceRoot = path.resolve(context?.workspaceRoot || context?.workspacePaths?.[0] || process.cwd());
|
|
376
|
+
// Resolve filesystem aliases before handing paths to Node's permission
|
|
377
|
+
// model. On macOS, /var and /tmp are symlinks into /private; authorizing the
|
|
378
|
+
// alias while the module loader opens the canonical target causes a false
|
|
379
|
+
// denial even though both names identify the same reviewed plugin file.
|
|
380
|
+
const runtimeTool = {
|
|
381
|
+
...tool,
|
|
382
|
+
entry: fs.realpathSync(tool.entry),
|
|
383
|
+
pluginDirectory: fs.realpathSync(tool.pluginDirectory)
|
|
384
|
+
};
|
|
385
|
+
const childArgs = [
|
|
386
|
+
...permissionArgs(runtimeTool, workspaceRoot),
|
|
387
|
+
RUNNER_FILE,
|
|
388
|
+
runtimeTool.entry,
|
|
389
|
+
runtimeTool.exportName
|
|
390
|
+
];
|
|
391
|
+
const payload = JSON.stringify({
|
|
392
|
+
args: safeObject(args),
|
|
393
|
+
context: {
|
|
394
|
+
workspaceRoot,
|
|
395
|
+
pluginRoot: runtimeTool.pluginDirectory,
|
|
396
|
+
capabilities: runtimeTool.capabilities
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return new Promise((resolve) => {
|
|
401
|
+
const child = spawn(process.execPath, childArgs, {
|
|
402
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
403
|
+
env: {
|
|
404
|
+
PATH: process.env.PATH || '',
|
|
405
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
406
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
let stdout = '';
|
|
410
|
+
let stderr = '';
|
|
411
|
+
let settled = false;
|
|
412
|
+
const finish = (value) => {
|
|
413
|
+
if (settled) return;
|
|
414
|
+
settled = true;
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
resolve(value);
|
|
417
|
+
};
|
|
418
|
+
const timer = setTimeout(() => {
|
|
419
|
+
child.kill('SIGTERM');
|
|
420
|
+
finish({ success: false, error: `Plugin timed out after ${runtimeTool.timeoutMs} ms` });
|
|
421
|
+
}, runtimeTool.timeoutMs);
|
|
422
|
+
if (timer.unref) timer.unref();
|
|
423
|
+
|
|
424
|
+
child.stdout.on('data', (chunk) => {
|
|
425
|
+
stdout += chunk;
|
|
426
|
+
if (Buffer.byteLength(stdout, 'utf8') > MAX_RESULT_BYTES) {
|
|
427
|
+
child.kill('SIGTERM');
|
|
428
|
+
finish({ success: false, error: 'Plugin result exceeded 1 MB' });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
child.stderr.on('data', (chunk) => {
|
|
432
|
+
stderr = (stderr + chunk).slice(-16_000);
|
|
433
|
+
});
|
|
434
|
+
child.on('error', (error) => finish({ success: false, error: error.message }));
|
|
435
|
+
child.on('close', () => {
|
|
436
|
+
const line = stdout.split(/\r?\n/).find((value) => value.startsWith('CAUSYNE_PLUGIN_RESULT:'));
|
|
437
|
+
if (!line) {
|
|
438
|
+
finish({ success: false, error: stderr.trim() || 'Plugin returned no structured result' });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
finish(JSON.parse(line.slice('CAUSYNE_PLUGIN_RESULT:'.length)));
|
|
443
|
+
} catch (error) {
|
|
444
|
+
finish({ success: false, error: `Plugin returned invalid JSON: ${error.message}` });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
child.stdin.end(payload);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function resolvePluginTool(name) {
|
|
452
|
+
const tool = enabledTools().find((candidate) => candidate.name === name);
|
|
453
|
+
if (!tool) return null;
|
|
454
|
+
return {
|
|
455
|
+
schema: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
456
|
+
execute: (args, context = {}) => runPluginTool(tool, args, context)
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_SKILLS_PER_PLUGIN = 32;
|
|
4
|
+
const MAX_SKILL_BYTES = 32 * 1024;
|
|
5
|
+
const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
function resolveInside(root, relativePath) {
|
|
7
|
+
const resolvedRoot = path.resolve(root);
|
|
8
|
+
const resolved = path.resolve(root, relativePath);
|
|
9
|
+
if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
10
|
+
throw new Error(`Skill path escapes plugin root: ${relativePath}`);
|
|
11
|
+
}
|
|
12
|
+
return resolved;
|
|
13
|
+
}
|
|
14
|
+
function cleanLine(value, maximum) {
|
|
15
|
+
return String(value ?? '')
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
.slice(0, maximum);
|
|
21
|
+
}
|
|
22
|
+
function readSkillFile(file) {
|
|
23
|
+
const stat = fs.lstatSync(file);
|
|
24
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
25
|
+
throw new Error(`${file} must be a regular file`);
|
|
26
|
+
if (stat.size > MAX_SKILL_BYTES)
|
|
27
|
+
throw new Error(`${file} exceeds ${MAX_SKILL_BYTES / 1024} KB`);
|
|
28
|
+
return fs.readFileSync(file, 'utf8')
|
|
29
|
+
// Keep newlines and tabs, but remove control bytes that can corrupt a prompt.
|
|
30
|
+
// eslint-disable-next-line no-control-regex
|
|
31
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate plugin-contributed skills without importing or evaluating plugin
|
|
35
|
+
* code. A skill becomes visible only after the caller verifies the plugin's
|
|
36
|
+
* whole-directory digest against the explicit enablement record.
|
|
37
|
+
*/
|
|
38
|
+
export function parsePluginSkills(directory, pluginName, value) {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
if (!Array.isArray(value))
|
|
42
|
+
throw new Error('Plugin skills must be an array');
|
|
43
|
+
if (value.length > MAX_SKILLS_PER_PLUGIN) {
|
|
44
|
+
throw new Error(`Plugin exceeds ${MAX_SKILLS_PER_PLUGIN} skills`);
|
|
45
|
+
}
|
|
46
|
+
const domain = `plugin-${pluginName}`;
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
return value.map((raw, index) => {
|
|
49
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
50
|
+
throw new Error(`Skill ${index + 1} must be an object`);
|
|
51
|
+
}
|
|
52
|
+
const input = raw;
|
|
53
|
+
const id = String(input.id ?? '');
|
|
54
|
+
if (!SKILL_ID.test(id))
|
|
55
|
+
throw new Error(`Invalid plugin skill id ${JSON.stringify(id)}`);
|
|
56
|
+
if (ids.has(id))
|
|
57
|
+
throw new Error(`Duplicate plugin skill id ${id}`);
|
|
58
|
+
ids.add(id);
|
|
59
|
+
const name = cleanLine(input.name ?? id, 120);
|
|
60
|
+
const description = cleanLine(input.description, 400);
|
|
61
|
+
if (!name)
|
|
62
|
+
throw new Error(`Plugin skill ${id} needs a name`);
|
|
63
|
+
if (!description)
|
|
64
|
+
throw new Error(`Plugin skill ${id} needs a description`);
|
|
65
|
+
if (typeof input.file !== 'string' || !input.file) {
|
|
66
|
+
throw new Error(`Plugin skill ${id} needs a file`);
|
|
67
|
+
}
|
|
68
|
+
const file = resolveInside(directory, input.file);
|
|
69
|
+
const content = readSkillFile(file);
|
|
70
|
+
if (!content.trim())
|
|
71
|
+
throw new Error(`Plugin skill ${id} is empty`);
|
|
72
|
+
return {
|
|
73
|
+
domain,
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
description,
|
|
77
|
+
content,
|
|
78
|
+
source: 'plugin',
|
|
79
|
+
plugin: pluginName,
|
|
80
|
+
path: file
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export { MAX_SKILLS_PER_PLUGIN, MAX_SKILL_BYTES };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MAX_SKILLS_PER_PLUGIN = 32;
|
|
5
|
+
const MAX_SKILL_BYTES = 32 * 1024;
|
|
6
|
+
const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
7
|
+
|
|
8
|
+
export interface PluginSkillManifestInput {
|
|
9
|
+
id?: unknown;
|
|
10
|
+
name?: unknown;
|
|
11
|
+
description?: unknown;
|
|
12
|
+
file?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PluginSkill {
|
|
16
|
+
domain: string;
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
content: string;
|
|
21
|
+
source: 'plugin';
|
|
22
|
+
plugin: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveInside(root: string, relativePath: string): string {
|
|
27
|
+
const resolvedRoot = path.resolve(root);
|
|
28
|
+
const resolved = path.resolve(root, relativePath);
|
|
29
|
+
if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
30
|
+
throw new Error(`Skill path escapes plugin root: ${relativePath}`);
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanLine(value: unknown, maximum: number): string {
|
|
36
|
+
return String(value ?? '')
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
.slice(0, maximum);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readSkillFile(file: string): string {
|
|
45
|
+
const stat = fs.lstatSync(file);
|
|
46
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
|
|
47
|
+
if (stat.size > MAX_SKILL_BYTES) throw new Error(`${file} exceeds ${MAX_SKILL_BYTES / 1024} KB`);
|
|
48
|
+
return fs.readFileSync(file, 'utf8')
|
|
49
|
+
// Keep newlines and tabs, but remove control bytes that can corrupt a prompt.
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate plugin-contributed skills without importing or evaluating plugin
|
|
56
|
+
* code. A skill becomes visible only after the caller verifies the plugin's
|
|
57
|
+
* whole-directory digest against the explicit enablement record.
|
|
58
|
+
*/
|
|
59
|
+
export function parsePluginSkills(
|
|
60
|
+
directory: string,
|
|
61
|
+
pluginName: string,
|
|
62
|
+
value: unknown
|
|
63
|
+
): PluginSkill[] {
|
|
64
|
+
if (value === undefined) return [];
|
|
65
|
+
if (!Array.isArray(value)) throw new Error('Plugin skills must be an array');
|
|
66
|
+
if (value.length > MAX_SKILLS_PER_PLUGIN) {
|
|
67
|
+
throw new Error(`Plugin exceeds ${MAX_SKILLS_PER_PLUGIN} skills`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const domain = `plugin-${pluginName}`;
|
|
71
|
+
const ids = new Set<string>();
|
|
72
|
+
return value.map((raw, index) => {
|
|
73
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
74
|
+
throw new Error(`Skill ${index + 1} must be an object`);
|
|
75
|
+
}
|
|
76
|
+
const input = raw as PluginSkillManifestInput;
|
|
77
|
+
const id = String(input.id ?? '');
|
|
78
|
+
if (!SKILL_ID.test(id)) throw new Error(`Invalid plugin skill id ${JSON.stringify(id)}`);
|
|
79
|
+
if (ids.has(id)) throw new Error(`Duplicate plugin skill id ${id}`);
|
|
80
|
+
ids.add(id);
|
|
81
|
+
|
|
82
|
+
const name = cleanLine(input.name ?? id, 120);
|
|
83
|
+
const description = cleanLine(input.description, 400);
|
|
84
|
+
if (!name) throw new Error(`Plugin skill ${id} needs a name`);
|
|
85
|
+
if (!description) throw new Error(`Plugin skill ${id} needs a description`);
|
|
86
|
+
if (typeof input.file !== 'string' || !input.file) {
|
|
87
|
+
throw new Error(`Plugin skill ${id} needs a file`);
|
|
88
|
+
}
|
|
89
|
+
const file = resolveInside(directory, input.file);
|
|
90
|
+
const content = readSkillFile(file);
|
|
91
|
+
if (!content.trim()) throw new Error(`Plugin skill ${id} is empty`);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
domain,
|
|
95
|
+
id,
|
|
96
|
+
name,
|
|
97
|
+
description,
|
|
98
|
+
content,
|
|
99
|
+
source: 'plugin' as const,
|
|
100
|
+
plugin: pluginName,
|
|
101
|
+
path: file
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { MAX_SKILLS_PER_PLUGIN, MAX_SKILL_BYTES };
|