openclaw-cascade-plugin 1.0.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/PHASE1_SUMMARY.md +191 -0
- package/PHASE3_SUMMARY.md +195 -0
- package/README.md +43 -0
- package/dist/a2a-client.d.ts +17 -0
- package/dist/a2a-client.d.ts.map +1 -0
- package/dist/a2a-client.js +47 -0
- package/dist/a2a-client.js.map +1 -0
- package/dist/cascade-client.d.ts +53 -0
- package/dist/cascade-client.d.ts.map +1 -0
- package/dist/cascade-client.js +179 -0
- package/dist/cascade-client.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +116 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +136 -0
- package/dist/index.js.map +1 -0
- package/dist/python-manager.d.ts +59 -0
- package/dist/python-manager.d.ts.map +1 -0
- package/dist/python-manager.js +190 -0
- package/dist/python-manager.js.map +1 -0
- package/dist/test-utils/helpers.d.ts +20 -0
- package/dist/test-utils/helpers.d.ts.map +1 -0
- package/dist/test-utils/helpers.js +89 -0
- package/dist/test-utils/helpers.js.map +1 -0
- package/dist/test-utils/index.d.ts +3 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/index.js +19 -0
- package/dist/test-utils/index.js.map +1 -0
- package/dist/test-utils/mocks.d.ts +51 -0
- package/dist/test-utils/mocks.d.ts.map +1 -0
- package/dist/test-utils/mocks.js +84 -0
- package/dist/test-utils/mocks.js.map +1 -0
- package/dist/tools/a2a-tools.d.ts +9 -0
- package/dist/tools/a2a-tools.d.ts.map +1 -0
- package/dist/tools/a2a-tools.js +147 -0
- package/dist/tools/a2a-tools.js.map +1 -0
- package/dist/tools/api-tools.d.ts +9 -0
- package/dist/tools/api-tools.d.ts.map +1 -0
- package/dist/tools/api-tools.js +102 -0
- package/dist/tools/api-tools.js.map +1 -0
- package/dist/tools/desktop-automation.d.ts +10 -0
- package/dist/tools/desktop-automation.d.ts.map +1 -0
- package/dist/tools/desktop-automation.js +330 -0
- package/dist/tools/desktop-automation.js.map +1 -0
- package/dist/tools/index.d.ts +12 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +35 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/response-helpers.d.ts +25 -0
- package/dist/tools/response-helpers.d.ts.map +1 -0
- package/dist/tools/response-helpers.js +71 -0
- package/dist/tools/response-helpers.js.map +1 -0
- package/dist/tools/sandbox-tools.d.ts +9 -0
- package/dist/tools/sandbox-tools.d.ts.map +1 -0
- package/dist/tools/sandbox-tools.js +79 -0
- package/dist/tools/sandbox-tools.js.map +1 -0
- package/dist/tools/tool-registry.d.ts +34 -0
- package/dist/tools/tool-registry.d.ts.map +1 -0
- package/dist/tools/tool-registry.js +50 -0
- package/dist/tools/tool-registry.js.map +1 -0
- package/dist/tools/web-automation.d.ts +9 -0
- package/dist/tools/web-automation.d.ts.map +1 -0
- package/dist/tools/web-automation.js +471 -0
- package/dist/tools/web-automation.js.map +1 -0
- package/dist/types/index.d.ts +111 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +38 -0
- package/dist/types/index.js.map +1 -0
- package/jest.setup.js +19 -0
- package/openclaw-cascade-plugin-1.0.0.tgz +0 -0
- package/openclaw.plugin.json +116 -0
- package/package.json +74 -0
- package/src/a2a-client.ts +66 -0
- package/src/cascade-client.test.ts +400 -0
- package/src/cascade-client.ts +198 -0
- package/src/config.test.ts +194 -0
- package/src/config.ts +135 -0
- package/src/index.ts +164 -0
- package/src/python-manager.test.ts +187 -0
- package/src/python-manager.ts +230 -0
- package/src/test-utils/helpers.ts +107 -0
- package/src/test-utils/index.ts +2 -0
- package/src/test-utils/mocks.ts +101 -0
- package/src/tools/a2a-tools.ts +162 -0
- package/src/tools/api-tools.ts +110 -0
- package/src/tools/desktop-automation.test.ts +305 -0
- package/src/tools/desktop-automation.ts +366 -0
- package/src/tools/index.ts +13 -0
- package/src/tools/response-helpers.ts +78 -0
- package/src/tools/sandbox-tools.ts +83 -0
- package/src/tools/tool-registry.ts +51 -0
- package/src/tools/web-automation.test.ts +177 -0
- package/src/tools/web-automation.ts +518 -0
- package/src/types/index.ts +132 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Config
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadConfig, validateConfig, getDefaults } from './config';
|
|
6
|
+
import { CascadePluginConfig } from './types';
|
|
7
|
+
|
|
8
|
+
describe('Config', () => {
|
|
9
|
+
const originalEnv = process.env;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
process.env = { ...originalEnv };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
process.env = originalEnv;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('getDefaults', () => {
|
|
20
|
+
test('should return default configuration', () => {
|
|
21
|
+
// Act
|
|
22
|
+
const defaults = getDefaults();
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(defaults).toEqual({
|
|
26
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
27
|
+
headless: false,
|
|
28
|
+
actionTimeoutMs: 8000,
|
|
29
|
+
enableA2A: true,
|
|
30
|
+
verbose: false,
|
|
31
|
+
screenshotMode: 'auto'
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('validateConfig', () => {
|
|
37
|
+
test('should pass with valid config', () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const config: CascadePluginConfig = {
|
|
40
|
+
cascadeGrpcEndpoint: 'localhost:50051'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Act & Assert
|
|
44
|
+
expect(() => validateConfig(config)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('should throw when cascadeGrpcEndpoint is missing', () => {
|
|
48
|
+
// Arrange
|
|
49
|
+
const config: CascadePluginConfig = {} as any;
|
|
50
|
+
|
|
51
|
+
// Act & Assert
|
|
52
|
+
expect(() => validateConfig(config)).toThrow('cascadeGrpcEndpoint is required');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('should throw when cascadeGrpcEndpoint is empty', () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const config: CascadePluginConfig = {
|
|
58
|
+
cascadeGrpcEndpoint: ''
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Act & Assert
|
|
62
|
+
expect(() => validateConfig(config)).toThrow('cascadeGrpcEndpoint is required');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('should validate firestore credentials path if provided', () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const config: CascadePluginConfig = {
|
|
68
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
69
|
+
firestoreCredentialsPath: '/path/to/creds.json'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Act & Assert
|
|
73
|
+
expect(() => validateConfig(config)).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('should validate allowed agents', () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const config: CascadePluginConfig = {
|
|
79
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
80
|
+
allowedAgents: ['explorer', 'worker', 'orchestrator']
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Act & Assert
|
|
84
|
+
expect(() => validateConfig(config)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('should throw for invalid agent in allowedAgents', () => {
|
|
88
|
+
// Arrange
|
|
89
|
+
const config: any = {
|
|
90
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
91
|
+
allowedAgents: ['explorer', 'invalid-agent']
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Act & Assert
|
|
95
|
+
expect(() => validateConfig(config)).toThrow('Invalid agent');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should validate screenshotMode', () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const config: CascadePluginConfig = {
|
|
101
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
102
|
+
screenshotMode: 'embed'
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Act & Assert
|
|
106
|
+
expect(() => validateConfig(config)).not.toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should throw for invalid screenshotMode', () => {
|
|
110
|
+
// Arrange
|
|
111
|
+
const config: any = {
|
|
112
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
113
|
+
screenshotMode: 'invalid'
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Act & Assert
|
|
117
|
+
expect(() => validateConfig(config)).toThrow('Invalid screenshotMode');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('loadConfig', () => {
|
|
122
|
+
test('should load config with defaults', () => {
|
|
123
|
+
// Arrange
|
|
124
|
+
const input = {
|
|
125
|
+
cascadeGrpcEndpoint: 'localhost:50051'
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Act
|
|
129
|
+
const config = loadConfig(input);
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
expect(config.cascadeGrpcEndpoint).toBe('localhost:50051');
|
|
133
|
+
expect(config.headless).toBe(false);
|
|
134
|
+
expect(config.actionTimeoutMs).toBe(8000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should merge with provided values', () => {
|
|
138
|
+
// Arrange
|
|
139
|
+
const input = {
|
|
140
|
+
cascadeGrpcEndpoint: '192.168.1.100:50051',
|
|
141
|
+
headless: true,
|
|
142
|
+
actionTimeoutMs: 15000
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
const config = loadConfig(input);
|
|
147
|
+
|
|
148
|
+
// Assert
|
|
149
|
+
expect(config.cascadeGrpcEndpoint).toBe('192.168.1.100:50051');
|
|
150
|
+
expect(config.headless).toBe(true);
|
|
151
|
+
expect(config.actionTimeoutMs).toBe(15000);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('should expand environment variables in paths', () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
process.env.HOME = '/home/user';
|
|
157
|
+
const input = {
|
|
158
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
159
|
+
firestoreCredentialsPath: '$HOME/creds.json'
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Act
|
|
163
|
+
const config = loadConfig(input);
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
expect(config.firestoreCredentialsPath).toBe('/home/user/creds.json');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('should handle Windows environment variables', () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
process.env.USERPROFILE = 'C:\\Users\\TestUser';
|
|
172
|
+
const input = {
|
|
173
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
174
|
+
cascadePythonPath: '%USERPROFILE%\\Python\\python.exe'
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
const config = loadConfig(input);
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
expect(config.cascadePythonPath).toBe('C:\\Users\\TestUser\\Python\\python.exe');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('should validate loaded config', () => {
|
|
185
|
+
// Arrange
|
|
186
|
+
const input = {
|
|
187
|
+
headless: true
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Act & Assert
|
|
191
|
+
expect(() => loadConfig(input as any)).toThrow('cascadeGrpcEndpoint is required');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management for OpenClaw Cascade Plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CascadePluginConfig } from './types';
|
|
6
|
+
|
|
7
|
+
const VALID_AGENTS = ['explorer', 'worker', 'orchestrator'];
|
|
8
|
+
const VALID_SCREENSHOT_MODES = ['embed', 'disk', 'auto'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get default configuration values
|
|
12
|
+
*/
|
|
13
|
+
export function getDefaults(): Partial<CascadePluginConfig> {
|
|
14
|
+
return {
|
|
15
|
+
cascadeGrpcEndpoint: 'localhost:50051',
|
|
16
|
+
headless: false,
|
|
17
|
+
actionTimeoutMs: 8000,
|
|
18
|
+
enableA2A: true,
|
|
19
|
+
verbose: false,
|
|
20
|
+
screenshotMode: 'auto'
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate configuration object
|
|
26
|
+
*/
|
|
27
|
+
export function validateConfig(config: CascadePluginConfig): void {
|
|
28
|
+
// Required fields
|
|
29
|
+
if (!config.cascadeGrpcEndpoint || config.cascadeGrpcEndpoint.trim() === '') {
|
|
30
|
+
throw new Error('cascadeGrpcEndpoint is required');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Validate gRPC endpoint format
|
|
34
|
+
const endpointPattern = /^[\w.-]+:\d+$/;
|
|
35
|
+
if (!endpointPattern.test(config.cascadeGrpcEndpoint)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid cascadeGrpcEndpoint format: ${config.cascadeGrpcEndpoint}. ` +
|
|
38
|
+
'Expected format: host:port (e.g., localhost:50051)'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Validate allowed agents if provided
|
|
43
|
+
if (config.allowedAgents) {
|
|
44
|
+
for (const agent of config.allowedAgents) {
|
|
45
|
+
if (!VALID_AGENTS.includes(agent)) {
|
|
46
|
+
throw new Error(`Invalid agent in allowedAgents: ${agent}. Valid agents: ${VALID_AGENTS.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validate screenshot mode if provided
|
|
52
|
+
if (config.screenshotMode && !VALID_SCREENSHOT_MODES.includes(config.screenshotMode)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid screenshotMode: ${config.screenshotMode}. ` +
|
|
55
|
+
`Valid modes: ${VALID_SCREENSHOT_MODES.join(', ')}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate action timeout
|
|
60
|
+
if (config.actionTimeoutMs !== undefined && config.actionTimeoutMs < 1000) {
|
|
61
|
+
throw new Error('actionTimeoutMs must be at least 1000ms');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Expand environment variables in a string
|
|
67
|
+
* Supports both $VAR and %VAR% syntax
|
|
68
|
+
*/
|
|
69
|
+
export function expandEnvVars(value: string): string {
|
|
70
|
+
if (!value) return value;
|
|
71
|
+
|
|
72
|
+
// Unix-style: $VAR or ${VAR}
|
|
73
|
+
let expanded = value.replace(/\$\{(\w+)\}/g, (match, varName) => {
|
|
74
|
+
return process.env[varName] || match;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expanded = expanded.replace(/\$(\w+)/g, (match, varName) => {
|
|
78
|
+
return process.env[varName] || match;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Windows-style: %VAR%
|
|
82
|
+
expanded = expanded.replace(/%(\w+)%/g, (match, varName) => {
|
|
83
|
+
return process.env[varName] || match;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return expanded;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load and validate configuration
|
|
91
|
+
*/
|
|
92
|
+
export function loadConfig(input: Partial<CascadePluginConfig>): CascadePluginConfig {
|
|
93
|
+
// Check required field before merging
|
|
94
|
+
if (!input.cascadeGrpcEndpoint) {
|
|
95
|
+
throw new Error('cascadeGrpcEndpoint is required');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Expand environment variables in string fields
|
|
99
|
+
const expanded: Partial<CascadePluginConfig> = {};
|
|
100
|
+
|
|
101
|
+
for (const [key, value] of Object.entries(input)) {
|
|
102
|
+
if (typeof value === 'string') {
|
|
103
|
+
(expanded as any)[key] = expandEnvVars(value);
|
|
104
|
+
} else {
|
|
105
|
+
(expanded as any)[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Merge with defaults
|
|
110
|
+
const config: CascadePluginConfig = {
|
|
111
|
+
...getDefaults(),
|
|
112
|
+
...expanded
|
|
113
|
+
} as CascadePluginConfig;
|
|
114
|
+
|
|
115
|
+
// Validate
|
|
116
|
+
validateConfig(config);
|
|
117
|
+
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load configuration from OpenClaw API
|
|
123
|
+
*/
|
|
124
|
+
export function loadConfigFromOpenClaw(api: any): CascadePluginConfig {
|
|
125
|
+
const pluginConfig = api.config?.plugins?.entries?.cascade?.config;
|
|
126
|
+
|
|
127
|
+
if (!pluginConfig) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'Cascade plugin configuration not found. ' +
|
|
130
|
+
'Please add cascade configuration to your OpenClaw config.'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return loadConfig(pluginConfig);
|
|
135
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Plugin Entry Point
|
|
3
|
+
*
|
|
4
|
+
* This is the main entry point for the @cascade/openclaw-plugin
|
|
5
|
+
* It initializes the plugin and registers all tools with OpenClaw
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PythonManager } from './python-manager';
|
|
9
|
+
import { CascadeMcpClient } from './cascade-client';
|
|
10
|
+
import { CascadeA2AClient } from './a2a-client';
|
|
11
|
+
import { loadConfig } from './config';
|
|
12
|
+
import { CascadeError } from './types';
|
|
13
|
+
import {
|
|
14
|
+
registerDesktopTools,
|
|
15
|
+
registerWebTools,
|
|
16
|
+
registerApiTools,
|
|
17
|
+
registerSandboxTools,
|
|
18
|
+
registerA2ATools
|
|
19
|
+
} from './tools';
|
|
20
|
+
import { ToolRegistry } from './tools/tool-registry';
|
|
21
|
+
|
|
22
|
+
// Placeholder for OpenClaw API type
|
|
23
|
+
interface OpenClawApi {
|
|
24
|
+
config: {
|
|
25
|
+
plugins: {
|
|
26
|
+
entries: {
|
|
27
|
+
cascade?: {
|
|
28
|
+
config?: any;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
registerTool: (tool: any) => void;
|
|
34
|
+
registerGatewayMethod: (name: string, handler: Function) => void;
|
|
35
|
+
registerCli: (handler: Function) => void;
|
|
36
|
+
notify: (message: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default async function register(api: OpenClawApi) {
|
|
40
|
+
let mcpClient: CascadeMcpClient | null = null;
|
|
41
|
+
let a2aClient: CascadeA2AClient | null = null;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Load and validate configuration
|
|
45
|
+
const config = loadConfig(api.config.plugins.entries.cascade?.config || {});
|
|
46
|
+
|
|
47
|
+
console.log('Initializing Cascade plugin...');
|
|
48
|
+
console.log(`gRPC Endpoint: ${config.cascadeGrpcEndpoint}`);
|
|
49
|
+
|
|
50
|
+
// Initialize Python Manager
|
|
51
|
+
const pythonManager = new PythonManager(config);
|
|
52
|
+
const pythonPath = await pythonManager.findOrInstallPython();
|
|
53
|
+
console.log(`Python: ${pythonPath}`);
|
|
54
|
+
|
|
55
|
+
// Initialize MCP Client
|
|
56
|
+
mcpClient = new CascadeMcpClient(pythonPath, {
|
|
57
|
+
CASCADE_GRPC_ENDPOINT: config.cascadeGrpcEndpoint,
|
|
58
|
+
CASCADE_APP_ID: config.firestoreProjectId || 'openclaw',
|
|
59
|
+
CASCADE_USER_ID: 'openclaw-user',
|
|
60
|
+
...(config.firestoreCredentialsPath && {
|
|
61
|
+
GOOGLE_APPLICATION_CREDENTIALS: config.firestoreCredentialsPath
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await mcpClient.start();
|
|
66
|
+
console.log('Cascade MCP client connected');
|
|
67
|
+
|
|
68
|
+
// Initialize A2A Client if enabled
|
|
69
|
+
if (config.enableA2A) {
|
|
70
|
+
a2aClient = new CascadeA2AClient(
|
|
71
|
+
config.cascadeGrpcEndpoint,
|
|
72
|
+
'openclaw-user',
|
|
73
|
+
config.firestoreProjectId || 'openclaw',
|
|
74
|
+
'' // auth token would come from config
|
|
75
|
+
);
|
|
76
|
+
await a2aClient.initialize();
|
|
77
|
+
console.log('Cascade A2A client initialized');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create tool registry
|
|
81
|
+
const toolRegistry = new ToolRegistry();
|
|
82
|
+
|
|
83
|
+
// Register all tools
|
|
84
|
+
console.log('Registering tools...');
|
|
85
|
+
registerDesktopTools(toolRegistry, mcpClient, config);
|
|
86
|
+
registerWebTools(toolRegistry, mcpClient);
|
|
87
|
+
registerApiTools(toolRegistry, mcpClient);
|
|
88
|
+
registerSandboxTools(toolRegistry, mcpClient);
|
|
89
|
+
|
|
90
|
+
if (a2aClient) {
|
|
91
|
+
registerA2ATools(toolRegistry, a2aClient);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Register tools with OpenClaw
|
|
95
|
+
const tools = toolRegistry.getAll();
|
|
96
|
+
console.log(`Registering ${tools.length} tools with OpenClaw`);
|
|
97
|
+
|
|
98
|
+
for (const tool of tools) {
|
|
99
|
+
api.registerTool({
|
|
100
|
+
name: tool.name,
|
|
101
|
+
description: tool.description,
|
|
102
|
+
inputSchema: tool.inputSchema,
|
|
103
|
+
handler: tool.handler
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Register status check
|
|
108
|
+
api.registerGatewayMethod('cascade.status', () => ({
|
|
109
|
+
connected: mcpClient?.isConnected() || false,
|
|
110
|
+
toolsRegistered: tools.length,
|
|
111
|
+
pythonPath,
|
|
112
|
+
grpcEndpoint: config.cascadeGrpcEndpoint,
|
|
113
|
+
a2aEnabled: config.enableA2A
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// Register CLI command
|
|
117
|
+
api.registerCli(({ program }: { program: any }) => {
|
|
118
|
+
program
|
|
119
|
+
.command('cascade:status')
|
|
120
|
+
.description('Check Cascade plugin status')
|
|
121
|
+
.action(() => {
|
|
122
|
+
console.log('Cascade Plugin Status:');
|
|
123
|
+
console.log(' Connected:', mcpClient?.isConnected() || false);
|
|
124
|
+
console.log(' Tools:', tools.length);
|
|
125
|
+
console.log(' Python:', pythonPath);
|
|
126
|
+
console.log(' gRPC:', config.cascadeGrpcEndpoint);
|
|
127
|
+
console.log(' A2A:', config.enableA2A ? 'enabled' : 'disabled');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
program
|
|
131
|
+
.command('cascade:tools')
|
|
132
|
+
.description('List all registered tools')
|
|
133
|
+
.action(() => {
|
|
134
|
+
console.log('Registered Tools:');
|
|
135
|
+
tools.forEach((tool, index) => {
|
|
136
|
+
console.log(` ${index + 1}. ${tool.name}`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log(`Cascade plugin initialized successfully with ${tools.length} tools`);
|
|
142
|
+
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error('Failed to initialize Cascade plugin:', error);
|
|
145
|
+
|
|
146
|
+
// Cleanup on error
|
|
147
|
+
if (mcpClient) {
|
|
148
|
+
mcpClient.stop();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (error instanceof CascadeError) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Cascade plugin initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Export types for TypeScript users
|
|
162
|
+
export * from './types';
|
|
163
|
+
export { PythonManager, CascadeMcpClient, CascadeA2AClient, loadConfig };
|
|
164
|
+
// Note: ToolRegistry is internal use only - not exported to avoid conflicts
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for PythonManager
|
|
3
|
+
*
|
|
4
|
+
* Test-driven development: Write tests first, then implement
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PythonManager } from './python-manager';
|
|
8
|
+
import { createMockConfig } from './test-utils';
|
|
9
|
+
|
|
10
|
+
describe('PythonManager', () => {
|
|
11
|
+
let pythonManager: PythonManager;
|
|
12
|
+
let mockExec: jest.Mock;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
mockExec = jest.fn();
|
|
17
|
+
|
|
18
|
+
// Mock the exec method directly on the module
|
|
19
|
+
jest.doMock('child_process', () => ({
|
|
20
|
+
exec: mockExec
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
pythonManager = new PythonManager(createMockConfig());
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
jest.dontMock('child_process');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('findOrInstallPython', () => {
|
|
31
|
+
test('should return configured path when cascadePythonPath is set', async () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
const config = createMockConfig({ cascadePythonPath: '/custom/python' });
|
|
34
|
+
pythonManager = new PythonManager(config);
|
|
35
|
+
|
|
36
|
+
// Mock the execAsync method directly
|
|
37
|
+
const execSpy = jest.spyOn(pythonManager as any, 'execAsync')
|
|
38
|
+
.mockResolvedValue({ stdout: 'Python 3.12.0', stderr: '' });
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const result = await pythonManager.findOrInstallPython();
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(result).toBe('/custom/python');
|
|
45
|
+
expect(execSpy).toHaveBeenCalledWith('/custom/python --version');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('should auto-detect python3.12 in common locations', async () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
let callCount = 0;
|
|
51
|
+
const execSpy = jest.spyOn(pythonManager as any, 'execAsync')
|
|
52
|
+
.mockImplementation(() => {
|
|
53
|
+
callCount++;
|
|
54
|
+
if (callCount <= 2) {
|
|
55
|
+
return Promise.reject(new Error('not found'));
|
|
56
|
+
}
|
|
57
|
+
return Promise.resolve({ stdout: 'Python 3.10.0', stderr: '' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
const result = await pythonManager.findOrInstallPython();
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(result).toBe('python3.10');
|
|
65
|
+
expect(execSpy).toHaveBeenCalledWith('python3.12 --version');
|
|
66
|
+
expect(execSpy).toHaveBeenCalledWith('python3.11 --version');
|
|
67
|
+
expect(execSpy).toHaveBeenCalledWith('python3.10 --version');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('should fallback through version candidates in order', async () => {
|
|
71
|
+
// Arrange
|
|
72
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
73
|
+
.mockResolvedValue({ stdout: 'Python 3.12.1', stderr: '' });
|
|
74
|
+
|
|
75
|
+
// Act
|
|
76
|
+
const result = await pythonManager.findOrInstallPython();
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(result).toBe('python3.12');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('should throw error when no Python found and auto-install fails', async () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
jest.spyOn(pythonManager as any, 'execAsync').mockRejectedValue(new Error('not found'));
|
|
85
|
+
|
|
86
|
+
// Mock install to fail
|
|
87
|
+
jest.spyOn(pythonManager as any, 'installPython').mockRejectedValue(
|
|
88
|
+
new Error('Installation failed')
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Act & Assert
|
|
92
|
+
await expect(pythonManager.findOrInstallPython()).rejects.toThrow('Installation failed');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('isValidPython', () => {
|
|
97
|
+
test('should return true for Python 3.10+', async () => {
|
|
98
|
+
// Arrange
|
|
99
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
100
|
+
.mockResolvedValue({ stdout: 'Python 3.12.0', stderr: '' });
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
const result = await (pythonManager as any).isValidPython('python3');
|
|
104
|
+
|
|
105
|
+
// Assert
|
|
106
|
+
expect(result).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should return true for Python 3.10.x', async () => {
|
|
110
|
+
// Arrange
|
|
111
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
112
|
+
.mockResolvedValue({ stdout: 'Python 3.10.5', stderr: '' });
|
|
113
|
+
|
|
114
|
+
// Act
|
|
115
|
+
const result = await (pythonManager as any).isValidPython('python3');
|
|
116
|
+
|
|
117
|
+
// Assert
|
|
118
|
+
expect(result).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('should return false for Python 3.9', async () => {
|
|
122
|
+
// Arrange
|
|
123
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
124
|
+
.mockResolvedValue({ stdout: 'Python 3.9.7', stderr: '' });
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
const result = await (pythonManager as any).isValidPython('python3');
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
expect(result).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('should return false for Python 2.x', async () => {
|
|
134
|
+
// Arrange
|
|
135
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
136
|
+
.mockResolvedValue({ stdout: 'Python 2.7.18', stderr: '' });
|
|
137
|
+
|
|
138
|
+
// Act
|
|
139
|
+
const result = await (pythonManager as any).isValidPython('python');
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
expect(result).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('should return false for non-Python executables', async () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
148
|
+
.mockResolvedValue({ stdout: 'not python output', stderr: '' });
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
const result = await (pythonManager as any).isValidPython('notpython');
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
154
|
+
expect(result).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should return false when command not found', async () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
jest.spyOn(pythonManager as any, 'execAsync')
|
|
160
|
+
.mockRejectedValue(new Error('command not found'));
|
|
161
|
+
|
|
162
|
+
// Act
|
|
163
|
+
const result = await (pythonManager as any).isValidPython('fakepython');
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
expect(result).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('parsePythonVersion', () => {
|
|
171
|
+
test('should parse standard version output', () => {
|
|
172
|
+
// Act & Assert
|
|
173
|
+
expect((pythonManager as any).parsePythonVersion('Python 3.12.0')).toBe(3.12);
|
|
174
|
+
expect((pythonManager as any).parsePythonVersion('Python 3.10.5')).toBe(3.10);
|
|
175
|
+
expect((pythonManager as any).parsePythonVersion('Python 3.9.0')).toBe(3.09);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('should handle version without patch', () => {
|
|
179
|
+
expect((pythonManager as any).parsePythonVersion('Python 3.12')).toBe(3.12);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('should return 0 for invalid output', () => {
|
|
183
|
+
expect((pythonManager as any).parsePythonVersion('not python')).toBe(0);
|
|
184
|
+
expect((pythonManager as any).parsePythonVersion('')).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|