@causyne/agent 1.10.13 → 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.
Files changed (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
@@ -0,0 +1,451 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import {
4
+ McpClient,
5
+ McpConnectionError,
6
+ type McpConnectionStatus,
7
+ type McpServerDefinition,
8
+ type McpToolCallResult,
9
+ type McpToolDefinition
10
+ } from './mcpClient.js';
11
+ import { loadConfig, saveConfig } from '../config.js';
12
+ import { auditLog } from '../auditLog.js';
13
+
14
+ const NAMESPACE = 'mcp__';
15
+ const RESOURCE_LIST_TOOL = '_causyne_resources_list';
16
+ const RESOURCE_READ_TOOL = '_causyne_resource_read';
17
+ const PROMPT_LIST_TOOL = '_causyne_prompts_list';
18
+ const PROMPT_GET_TOOL = '_causyne_prompt_get';
19
+ const connected = new Map<string, McpClient>();
20
+ const connecting = new Map<string, Promise<McpClient>>();
21
+ const statuses = new Map<string, McpConnectionStatus>();
22
+ const connectionEpochs = new Map<string, number>();
23
+
24
+ interface ConsentRecord {
25
+ digest: string;
26
+ grantedAt: number;
27
+ }
28
+
29
+ interface ConfigShape {
30
+ mcpServers?: unknown[];
31
+ mcpConsent?: Record<string, unknown>;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ interface DynamicToolDefinition {
36
+ name: string;
37
+ description: string;
38
+ parameters: Record<string, unknown>;
39
+ }
40
+
41
+ type ToolResult = McpToolCallResult & { requiresConsent?: boolean };
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function cloneStringRecord(value: unknown): Record<string, string> | undefined {
48
+ if (!isRecord(value)) return undefined;
49
+ const result: Record<string, string> = {};
50
+ for (const [key, item] of Object.entries(value)) {
51
+ if (typeof item === 'string') result[key] = item;
52
+ }
53
+ return Object.keys(result).length > 0 ? result : undefined;
54
+ }
55
+
56
+ function cloneStringArray(value: unknown): string[] | undefined {
57
+ if (!Array.isArray(value)) return undefined;
58
+ return value.filter((item): item is string => typeof item === 'string');
59
+ }
60
+
61
+ function normalizeDefinition(value: unknown): McpServerDefinition | null {
62
+ if (!isRecord(value) || typeof value.name !== 'string') return null;
63
+ const definition: McpServerDefinition = { name: value.name };
64
+ if (typeof value.command === 'string') definition.command = value.command;
65
+ if (typeof value.url === 'string') definition.url = value.url;
66
+ const args = cloneStringArray(value.args);
67
+ const env = cloneStringRecord(value.env);
68
+ const headers = cloneStringRecord(value.headers);
69
+ if (args) definition.args = args;
70
+ if (env) definition.env = env;
71
+ if (headers) definition.headers = headers;
72
+ if (typeof value.cwd === 'string') definition.cwd = value.cwd;
73
+ if (typeof value.enabled === 'boolean') definition.enabled = value.enabled;
74
+ if (typeof value.timeoutMs === 'number') definition.timeoutMs = value.timeoutMs;
75
+ if (value.protocol === 'legacy' || value.protocol === 'auto') definition.protocol = value.protocol;
76
+ if (isRecord(value.tools)) {
77
+ const include = cloneStringArray(value.tools.include);
78
+ const exclude = cloneStringArray(value.tools.exclude);
79
+ if (include || exclude) {
80
+ definition.tools = {};
81
+ if (include) definition.tools.include = include;
82
+ if (exclude) definition.tools.exclude = exclude;
83
+ }
84
+ }
85
+ return McpClient.validateDefinition(definition) === null ? definition : null;
86
+ }
87
+
88
+ function canonicalize(value: unknown): unknown {
89
+ if (Array.isArray(value)) return value.map(canonicalize);
90
+ if (!isRecord(value)) return value;
91
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalize(value[key])]));
92
+ }
93
+
94
+ export function definitionDigest(definition: McpServerDefinition): string {
95
+ return createHash('sha256').update(JSON.stringify(canonicalize(definition))).digest('hex');
96
+ }
97
+
98
+ function consentRecord(value: unknown): ConsentRecord | null {
99
+ if (!isRecord(value) || typeof value.digest !== 'string' || typeof value.grantedAt !== 'number') return null;
100
+ return { digest: value.digest, grantedAt: value.grantedAt };
101
+ }
102
+
103
+ function globMatches(name: string, pattern: string): boolean {
104
+ const source = pattern
105
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
106
+ .replaceAll('*', '.*')
107
+ .replaceAll('?', '.');
108
+ return new RegExp(`^${source}$`, 'u').test(name);
109
+ }
110
+
111
+ function toolAllowed(server: McpServerDefinition, toolName: string): boolean {
112
+ const include = server.tools?.include;
113
+ const exclude = server.tools?.exclude;
114
+ if (include && !include.some(pattern => globMatches(toolName, pattern))) return false;
115
+ if (exclude?.some(pattern => globMatches(toolName, pattern))) return false;
116
+ return true;
117
+ }
118
+
119
+ function utilityDefinitions(server: McpServerDefinition, client: McpClient): DynamicToolDefinition[] {
120
+ const definitions: DynamicToolDefinition[] = [];
121
+ if (client.supportsResources()) {
122
+ definitions.push(
123
+ {
124
+ name: toolName(server.name, RESOURCE_LIST_TOOL),
125
+ description: `[MCP: ${server.name}] List the resources and resource templates exposed by this server.`,
126
+ parameters: { type: 'object', properties: {}, additionalProperties: false }
127
+ },
128
+ {
129
+ name: toolName(server.name, RESOURCE_READ_TOOL),
130
+ description: `[MCP: ${server.name}] Read one resource by its exact URI.`,
131
+ parameters: {
132
+ type: 'object',
133
+ properties: { uri: { type: 'string', description: 'Exact MCP resource URI.' } },
134
+ required: ['uri'],
135
+ additionalProperties: false
136
+ }
137
+ }
138
+ );
139
+ }
140
+ if (client.supportsPrompts()) {
141
+ definitions.push(
142
+ {
143
+ name: toolName(server.name, PROMPT_LIST_TOOL),
144
+ description: `[MCP: ${server.name}] List the prompts exposed by this server.`,
145
+ parameters: { type: 'object', properties: {}, additionalProperties: false }
146
+ },
147
+ {
148
+ name: toolName(server.name, PROMPT_GET_TOOL),
149
+ description: `[MCP: ${server.name}] Get a prompt by name with optional string arguments.`,
150
+ parameters: {
151
+ type: 'object',
152
+ properties: {
153
+ name: { type: 'string', description: 'Exact MCP prompt name.' },
154
+ arguments: { type: 'object', additionalProperties: { type: 'string' } }
155
+ },
156
+ required: ['name'],
157
+ additionalProperties: false
158
+ }
159
+ }
160
+ );
161
+ }
162
+ return definitions.filter(definition => toolAllowed(server, parseToolName(definition.name)?.tool ?? ''));
163
+ }
164
+
165
+ function config(): ConfigShape {
166
+ return loadConfig() as ConfigShape;
167
+ }
168
+
169
+ function connectionEpoch(name: string): number {
170
+ return connectionEpochs.get(name) ?? 0;
171
+ }
172
+
173
+ function invalidateConnection(name: string): void {
174
+ connectionEpochs.set(name, connectionEpoch(name) + 1);
175
+ }
176
+
177
+ /** Server definitions the user configured. Model output never reaches this source. */
178
+ export function configuredServers(): McpServerDefinition[] {
179
+ const servers = config().mcpServers;
180
+ if (!Array.isArray(servers)) return [];
181
+ return servers.map(normalizeDefinition).filter((server): server is McpServerDefinition => server !== null);
182
+ }
183
+
184
+ export function addServer(value: unknown): { ok: boolean; error?: string } {
185
+ const problem = McpClient.validateDefinition(value);
186
+ if (problem) return { ok: false, error: problem };
187
+ const definition = normalizeDefinition(value);
188
+ if (!definition) return { ok: false, error: 'invalid server definition' };
189
+
190
+ const current = config();
191
+ const servers = Array.isArray(current.mcpServers) ? [...current.mcpServers] : [];
192
+ if (servers.some(server => isRecord(server) && server.name === definition.name)) {
193
+ return { ok: false, error: `a server named "${definition.name}" is already configured` };
194
+ }
195
+
196
+ current.mcpServers = [...servers, definition];
197
+ current.mcpConsent = { ...(current.mcpConsent ?? {}), [definition.name]: false };
198
+ saveConfig(current);
199
+ statuses.set(definition.name, definition.enabled === false ? { state: 'disabled' } : { state: 'disconnected' });
200
+ auditLog.log({ event: 'mcp_server_added', server: definition.name, transport: definition.url ? 'http' : 'stdio' });
201
+ return { ok: true };
202
+ }
203
+
204
+ export function removeServer(name: string): { ok: boolean; error?: string } {
205
+ const current = config();
206
+ const servers = Array.isArray(current.mcpServers) ? current.mcpServers : [];
207
+ if (!servers.some(server => isRecord(server) && server.name === name)) {
208
+ return { ok: false, error: `no server named "${name}" is configured` };
209
+ }
210
+
211
+ current.mcpServers = servers.filter(server => !isRecord(server) || server.name !== name);
212
+ const consent = { ...(current.mcpConsent ?? {}) };
213
+ delete consent[name];
214
+ current.mcpConsent = consent;
215
+ saveConfig(current);
216
+ void disconnectServer(name);
217
+ statuses.delete(name);
218
+ auditLog.log({ event: 'mcp_server_removed', server: name });
219
+ return { ok: true };
220
+ }
221
+
222
+ export function isServerGranted(name: string): boolean {
223
+ const definition = configuredServers().find(server => server.name === name);
224
+ if (!definition || definition.enabled === false) return false;
225
+ const consent = consentRecord(config().mcpConsent?.[name]);
226
+ return consent?.digest === definitionDigest(definition);
227
+ }
228
+
229
+ export function grantServer(name: string): { ok: boolean; error?: string } {
230
+ const definition = configuredServers().find(server => server.name === name);
231
+ if (!definition) return { ok: false, error: `no server named "${name}" is configured` };
232
+ const current = config();
233
+ const digest = definitionDigest(definition);
234
+ current.mcpConsent = {
235
+ ...(current.mcpConsent ?? {}),
236
+ [name]: { digest, grantedAt: Date.now() }
237
+ };
238
+ saveConfig(current);
239
+ statuses.set(name, definition.enabled === false ? { state: 'disabled' } : { state: 'disconnected' });
240
+ auditLog.log({ event: 'mcp_server_granted', server: name, definitionDigest: digest.slice(0, 12) });
241
+ return { ok: true };
242
+ }
243
+
244
+ export function revokeServer(name: string): void {
245
+ const current = config();
246
+ current.mcpConsent = { ...(current.mcpConsent ?? {}), [name]: false };
247
+ saveConfig(current);
248
+ void disconnectServer(name);
249
+ statuses.set(name, { state: 'disconnected' });
250
+ auditLog.log({ event: 'mcp_server_revoked', server: name });
251
+ }
252
+
253
+ export function toolName(serverName: string, tool: string): string {
254
+ return `${NAMESPACE}${serverName}__${tool}`;
255
+ }
256
+
257
+ export function isMcpToolName(name: unknown): name is string {
258
+ return typeof name === 'string' && name.startsWith(NAMESPACE);
259
+ }
260
+
261
+ export function parseToolName(name: unknown): { server: string; tool: string } | null {
262
+ if (!isMcpToolName(name)) return null;
263
+ const rest = name.slice(NAMESPACE.length);
264
+ const separator = rest.indexOf('__');
265
+ if (separator <= 0 || separator === rest.length - 2) return null;
266
+ return { server: rest.slice(0, separator), tool: rest.slice(separator + 2) };
267
+ }
268
+
269
+ export async function connectServer(name: string): Promise<McpClient> {
270
+ const definition = configuredServers().find(server => server.name === name);
271
+ if (!definition) throw new Error(`no MCP server named "${name}" is configured`);
272
+ if (definition.enabled === false) {
273
+ statuses.set(name, { state: 'disabled' });
274
+ throw new Error(`MCP server "${name}" is disabled`);
275
+ }
276
+ if (!isServerGranted(name)) throw new Error(`MCP server "${name}" has not been granted for its current configuration`);
277
+
278
+ const live = connected.get(name);
279
+ if (live && live.status.state === 'connected' && definitionDigest(live.definition) === definitionDigest(definition)) {
280
+ return live;
281
+ }
282
+ if (live) await disconnectServer(name);
283
+ const pending = connecting.get(name);
284
+ if (pending) return pending;
285
+
286
+ const startedAtEpoch = connectionEpoch(name);
287
+ let promise!: Promise<McpClient>;
288
+ promise = (async () => {
289
+ const client = new McpClient(definition, {
290
+ workspace: process.cwd(),
291
+ onStatus: status => {
292
+ if (connectionEpoch(name) !== startedAtEpoch) return;
293
+ statuses.set(name, status);
294
+ if (status.state === 'failed' || status.state === 'needs_auth') connected.delete(name);
295
+ }
296
+ });
297
+ statuses.set(name, { state: 'connecting' });
298
+ try {
299
+ await client.connect();
300
+ const current = configuredServers().find(server => server.name === name);
301
+ const connectionIsCurrent = connectionEpoch(name) === startedAtEpoch
302
+ && current !== undefined
303
+ && definitionDigest(current) === definitionDigest(definition)
304
+ && isServerGranted(name);
305
+ if (!connectionIsCurrent) {
306
+ await client.close();
307
+ throw new Error(`MCP server "${name}" connection was cancelled`);
308
+ }
309
+ connected.set(name, client);
310
+ statuses.set(name, client.status);
311
+ return client;
312
+ } catch (error) {
313
+ const status = error instanceof McpConnectionError
314
+ ? { state: error.state, error: error.message } satisfies McpConnectionStatus
315
+ : { state: 'failed', error: error instanceof Error ? error.message : String(error) } satisfies McpConnectionStatus;
316
+ if (connectionEpoch(name) === startedAtEpoch) statuses.set(name, status);
317
+ throw error;
318
+ } finally {
319
+ if (connecting.get(name) === promise) connecting.delete(name);
320
+ }
321
+ })();
322
+ connecting.set(name, promise);
323
+ return promise;
324
+ }
325
+
326
+ export async function disconnectServer(name: string): Promise<void> {
327
+ invalidateConnection(name);
328
+ const client = connected.get(name);
329
+ connected.delete(name);
330
+ connecting.delete(name);
331
+ if (client) await client.close();
332
+ if (configuredServers().some(server => server.name === name)) statuses.set(name, { state: 'disconnected' });
333
+ }
334
+
335
+ export async function refreshServer(name: string): Promise<McpToolDefinition[]> {
336
+ const client = await connectServer(name);
337
+ return client.refreshTools();
338
+ }
339
+
340
+ export async function discoverTools(): Promise<DynamicToolDefinition[]> {
341
+ const granted = configuredServers().filter(server => isServerGranted(server.name));
342
+ const results = await Promise.all(granted.map(async server => {
343
+ try {
344
+ const client = await connectServer(server.name);
345
+ return client.tools
346
+ .filter(tool => toolAllowed(server, tool.name))
347
+ .map(tool => ({
348
+ name: toolName(server.name, tool.name),
349
+ description: `[MCP: ${server.name}] ${tool.description}`.trim(),
350
+ parameters: tool.inputSchema
351
+ }))
352
+ .concat(utilityDefinitions(server, client));
353
+ } catch (error) {
354
+ const message = error instanceof Error ? error.message : String(error);
355
+ console.warn(`[MCP] ${server.name} unavailable: ${message}`);
356
+ return [];
357
+ }
358
+ }));
359
+ return results.flat();
360
+ }
361
+
362
+ export function resolveMcpTool(name: unknown): { schema: DynamicToolDefinition; execute: (args: Record<string, unknown>, context?: { signal?: AbortSignal }) => Promise<ToolResult> } | null {
363
+ const parsed = parseToolName(name);
364
+ if (!parsed) return null;
365
+ const definition = configuredServers().find(server => server.name === parsed.server);
366
+ if (!definition) return null;
367
+
368
+ return {
369
+ schema: {
370
+ name: name as string,
371
+ description: `MCP tool ${parsed.tool} on server ${parsed.server}`,
372
+ parameters: { type: 'object', properties: {} }
373
+ },
374
+ async execute(args, context = {}) {
375
+ if (!isServerGranted(parsed.server)) {
376
+ return {
377
+ success: false,
378
+ requiresConsent: true,
379
+ error: `The MCP server "${parsed.server}" is configured but not enabled for its current definition. ` +
380
+ `Request approval with manage_local_dependencies action "grant_mcp_server" and group "${parsed.server}".`
381
+ };
382
+ }
383
+ try {
384
+ const client = await connectServer(parsed.server);
385
+ if (parsed.tool === RESOURCE_LIST_TOOL) return await client.listResources(context.signal);
386
+ if (parsed.tool === RESOURCE_READ_TOOL) {
387
+ return typeof args.uri === 'string'
388
+ ? await client.readResource(args.uri, context.signal)
389
+ : { success: false, error: '`uri` must be a string' };
390
+ }
391
+ if (parsed.tool === PROMPT_LIST_TOOL) return await client.listPrompts(context.signal);
392
+ if (parsed.tool === PROMPT_GET_TOOL) {
393
+ if (typeof args.name !== 'string') return { success: false, error: '`name` must be a string' };
394
+ const promptArgs: Record<string, string> = {};
395
+ if (isRecord(args.arguments)) {
396
+ for (const [key, value] of Object.entries(args.arguments)) {
397
+ if (typeof value !== 'string') return { success: false, error: '`arguments` values must be strings' };
398
+ promptArgs[key] = value;
399
+ }
400
+ }
401
+ return await client.getPrompt(args.name, promptArgs, context.signal);
402
+ }
403
+ const tool = client.tools.find(item => item.name === parsed.tool);
404
+ if (!tool || !toolAllowed(definition, parsed.tool)) {
405
+ return { success: false, error: `MCP server "${parsed.server}" does not expose permitted tool "${parsed.tool}"` };
406
+ }
407
+ return await client.callTool(parsed.tool, args ?? {}, context.signal);
408
+ } catch (error) {
409
+ return { success: false, error: `MCP server "${parsed.server}": ${error instanceof Error ? error.message : String(error)}` };
410
+ }
411
+ }
412
+ };
413
+ }
414
+
415
+ export function serverStatus(): Array<{
416
+ name: string;
417
+ transport: string;
418
+ target: string;
419
+ granted: boolean;
420
+ connected: boolean;
421
+ status: McpConnectionStatus['state'];
422
+ error?: string;
423
+ tools: string[];
424
+ }> {
425
+ return configuredServers().map(server => {
426
+ const live = connected.get(server.name);
427
+ const status = server.enabled === false
428
+ ? { state: 'disabled' as const }
429
+ : statuses.get(server.name) ?? { state: 'disconnected' as const };
430
+ const result = {
431
+ name: server.name,
432
+ transport: status.transport ?? (server.url ? 'http' : 'stdio'),
433
+ target: server.url ?? [server.command, ...(server.args ?? [])].filter(Boolean).join(' '),
434
+ granted: isServerGranted(server.name),
435
+ connected: status.state === 'connected',
436
+ status: status.state,
437
+ tools: live?.tools.filter(tool => toolAllowed(server, tool.name)).map(tool => tool.name) ?? []
438
+ };
439
+ return status.error ? { ...result, error: status.error } : result;
440
+ });
441
+ }
442
+
443
+ export async function closeAll(): Promise<void> {
444
+ for (const name of new Set([...connected.keys(), ...connecting.keys()])) invalidateConnection(name);
445
+ const clients = [...connected.values()];
446
+ connected.clear();
447
+ connecting.clear();
448
+ await Promise.allSettled(clients.map(client => client.close()));
449
+ statuses.clear();
450
+ connectionEpochs.clear();
451
+ }