cli4ai 1.0.3 → 1.1.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/package.json +3 -2
- package/src/cli.ts +104 -1
- package/src/commands/remotes.ts +253 -0
- package/src/commands/routines.ts +27 -8
- package/src/commands/run.ts +83 -3
- package/src/commands/serve.ts +66 -0
- package/src/core/config.ts +24 -2
- package/src/core/execute.ts +60 -0
- package/src/core/link.ts +17 -1
- package/src/core/remote-client.ts +419 -0
- package/src/core/remotes.ts +268 -0
- package/src/core/routine-engine.ts +91 -23
- package/src/core/routines.ts +9 -6
- package/src/core/scheduler.ts +2 -2
- package/src/mcp/server.ts +8 -0
- package/src/server/service.ts +434 -0
package/src/core/config.ts
CHANGED
|
@@ -104,6 +104,12 @@ export interface Config {
|
|
|
104
104
|
port: number;
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
// Audit logging configuration
|
|
108
|
+
audit: {
|
|
109
|
+
/** Enable audit logging for MCP tool calls */
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
107
113
|
// Telemetry (future)
|
|
108
114
|
telemetry: boolean;
|
|
109
115
|
}
|
|
@@ -128,6 +134,9 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
128
134
|
transport: 'stdio',
|
|
129
135
|
port: 3100
|
|
130
136
|
},
|
|
137
|
+
audit: {
|
|
138
|
+
enabled: true
|
|
139
|
+
},
|
|
131
140
|
telemetry: false
|
|
132
141
|
};
|
|
133
142
|
|
|
@@ -197,6 +206,12 @@ function deepMerge(target: Config, source: Partial<Config>): Config {
|
|
|
197
206
|
port: source.mcp.port ?? target.mcp.port
|
|
198
207
|
};
|
|
199
208
|
}
|
|
209
|
+
// Deep merge audit config
|
|
210
|
+
if (source.audit !== undefined) {
|
|
211
|
+
result.audit = {
|
|
212
|
+
enabled: source.audit.enabled ?? target.audit.enabled
|
|
213
|
+
};
|
|
214
|
+
}
|
|
200
215
|
|
|
201
216
|
return result;
|
|
202
217
|
}
|
|
@@ -334,7 +349,14 @@ export function addLocalRegistry(path: string): void {
|
|
|
334
349
|
*/
|
|
335
350
|
export function removeLocalRegistry(path: string): void {
|
|
336
351
|
const absolutePath = resolve(path);
|
|
337
|
-
|
|
352
|
+
|
|
353
|
+
// SECURITY: Validate symlink target consistently with addLocalRegistry
|
|
354
|
+
const safePath = validateSymlinkTarget(absolutePath);
|
|
355
|
+
if (!safePath) {
|
|
356
|
+
outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
|
|
357
|
+
hint: 'Symlinks must point to directories within safe locations'
|
|
358
|
+
});
|
|
359
|
+
}
|
|
338
360
|
|
|
339
361
|
let removed = false;
|
|
340
362
|
updateConfig((config) => {
|
|
@@ -536,7 +558,7 @@ export function getGlobalPackages(): InstalledPackage[] {
|
|
|
536
558
|
name: manifest.name,
|
|
537
559
|
version: manifest.version,
|
|
538
560
|
path: safePath,
|
|
539
|
-
source: '
|
|
561
|
+
source: 'registry',
|
|
540
562
|
installedAt: new Date().toISOString()
|
|
541
563
|
});
|
|
542
564
|
} catch {
|
package/src/core/execute.ts
CHANGED
|
@@ -31,6 +31,14 @@ function expandTilde(path: string): string {
|
|
|
31
31
|
|
|
32
32
|
export type ExecuteCaptureMode = 'inherit' | 'pipe';
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Permission scope levels for tool execution
|
|
36
|
+
* - read: Only allow read operations (no mutations)
|
|
37
|
+
* - write: Allow write operations but no destructive actions
|
|
38
|
+
* - full: Full access (default)
|
|
39
|
+
*/
|
|
40
|
+
export type ScopeLevel = 'read' | 'write' | 'full';
|
|
41
|
+
|
|
34
42
|
export interface ExecuteToolOptions {
|
|
35
43
|
packageName: string;
|
|
36
44
|
command?: string;
|
|
@@ -41,6 +49,10 @@ export interface ExecuteToolOptions {
|
|
|
41
49
|
capture: ExecuteCaptureMode;
|
|
42
50
|
timeoutMs?: number;
|
|
43
51
|
teeStderr?: boolean;
|
|
52
|
+
/** Permission scope for the tool */
|
|
53
|
+
scope?: ScopeLevel;
|
|
54
|
+
/** Run in sandboxed environment with restricted file system access */
|
|
55
|
+
sandbox?: boolean;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
export interface ExecuteToolResult {
|
|
@@ -348,6 +360,39 @@ function buildRuntimeCommand(entryPath: string, cmdArgs: string[]): { execCmd: s
|
|
|
348
360
|
return { execCmd: 'node', execArgs: [entryPath, ...cmdArgs], runtime: 'node' };
|
|
349
361
|
}
|
|
350
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Build security environment variables for scope and sandbox restrictions
|
|
365
|
+
*/
|
|
366
|
+
function buildSecurityEnv(
|
|
367
|
+
scope: ScopeLevel,
|
|
368
|
+
sandbox: boolean,
|
|
369
|
+
cwd: string
|
|
370
|
+
): Record<string, string> {
|
|
371
|
+
const env: Record<string, string> = {};
|
|
372
|
+
|
|
373
|
+
// Set scope environment variable for tools to respect
|
|
374
|
+
env.CLI4AI_SCOPE = scope;
|
|
375
|
+
|
|
376
|
+
// Sandbox restrictions
|
|
377
|
+
if (sandbox) {
|
|
378
|
+
env.CLI4AI_SANDBOX = '1';
|
|
379
|
+
|
|
380
|
+
// Restrict file system access to temp directories and package directory
|
|
381
|
+
// Tools should check these env vars and restrict their operations
|
|
382
|
+
const tmpDir = process.env.TMPDIR || process.env.TMP || process.env.TEMP || '/tmp';
|
|
383
|
+
env.CLI4AI_SANDBOX_ALLOWED_PATHS = [
|
|
384
|
+
tmpDir,
|
|
385
|
+
cwd, // Allow access to current working directory
|
|
386
|
+
].join(':');
|
|
387
|
+
|
|
388
|
+
// Restrict network access in sandbox mode
|
|
389
|
+
// Tools should check this and limit network operations
|
|
390
|
+
env.CLI4AI_SANDBOX_NETWORK = 'restricted';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return env;
|
|
394
|
+
}
|
|
395
|
+
|
|
351
396
|
async function ensureRuntimeAvailable(): Promise<void> {
|
|
352
397
|
if (!commandExists('node')) {
|
|
353
398
|
log('⚠️ Node.js is required to run this tool\n');
|
|
@@ -409,6 +454,19 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
|
|
|
409
454
|
|
|
410
455
|
const teeStderr = options.teeStderr ?? true;
|
|
411
456
|
|
|
457
|
+
// Build security environment for scope and sandbox
|
|
458
|
+
const scope = options.scope ?? 'full';
|
|
459
|
+
const sandbox = options.sandbox ?? false;
|
|
460
|
+
const securityEnv = buildSecurityEnv(scope, sandbox, invocationDir);
|
|
461
|
+
|
|
462
|
+
// Log security restrictions if active
|
|
463
|
+
if (scope !== 'full' || sandbox) {
|
|
464
|
+
const restrictions: string[] = [];
|
|
465
|
+
if (scope !== 'full') restrictions.push(`scope=${scope}`);
|
|
466
|
+
if (sandbox) restrictions.push('sandbox=enabled');
|
|
467
|
+
log(`🔒 Security: ${restrictions.join(', ')}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
412
470
|
if (options.capture === 'inherit') {
|
|
413
471
|
const proc = spawn(execCmd, execArgs, {
|
|
414
472
|
stdio: 'inherit',
|
|
@@ -421,6 +479,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
|
|
|
421
479
|
C4AI_PACKAGE_NAME: pkg.name,
|
|
422
480
|
C4AI_ENTRY: entryPath,
|
|
423
481
|
...secretsEnv,
|
|
482
|
+
...securityEnv,
|
|
424
483
|
...(options.env ?? {})
|
|
425
484
|
}
|
|
426
485
|
});
|
|
@@ -453,6 +512,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
|
|
|
453
512
|
C4AI_PACKAGE_NAME: pkg.name,
|
|
454
513
|
C4AI_ENTRY: entryPath,
|
|
455
514
|
...secretsEnv,
|
|
515
|
+
...securityEnv,
|
|
456
516
|
...(options.env ?? {})
|
|
457
517
|
}
|
|
458
518
|
});
|
package/src/core/link.ts
CHANGED
|
@@ -204,6 +204,21 @@ export function isPackageLinked(packageName: string): boolean {
|
|
|
204
204
|
* Get PATH setup instructions
|
|
205
205
|
*/
|
|
206
206
|
export function getPathInstructions(): string {
|
|
207
|
+
if (process.platform === 'win32') {
|
|
208
|
+
return `
|
|
209
|
+
To use globally installed cli4ai packages from anywhere, add the bin directory to your PATH:
|
|
210
|
+
|
|
211
|
+
${C4AI_BIN}
|
|
212
|
+
|
|
213
|
+
On Windows, you can:
|
|
214
|
+
1. Use System Settings > Edit environment variables (GUI)
|
|
215
|
+
2. Or run in PowerShell (current session): $env:PATH += ";${C4AI_BIN}"
|
|
216
|
+
3. Or run in PowerShell (permanent): [Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";${C4AI_BIN}", "User")
|
|
217
|
+
|
|
218
|
+
Then restart your terminal.
|
|
219
|
+
`.trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
207
222
|
const shell = process.env.SHELL || '/bin/bash';
|
|
208
223
|
const isZsh = shell.includes('zsh');
|
|
209
224
|
const rcFile = isZsh ? '~/.zshrc' : '~/.bashrc';
|
|
@@ -226,5 +241,6 @@ Or start a new terminal session.
|
|
|
226
241
|
*/
|
|
227
242
|
export function isBinInPath(): boolean {
|
|
228
243
|
const pathEnv = process.env.PATH || '';
|
|
229
|
-
|
|
244
|
+
const separator = process.platform === 'win32' ? ';' : ':';
|
|
245
|
+
return pathEnv.split(separator).some(p => resolve(p) === C4AI_BIN);
|
|
230
246
|
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote client for executing cli4ai commands on remote hosts.
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions to call remote cli4ai services
|
|
5
|
+
* configured via `cli4ai remotes add`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { request as httpRequest, type RequestOptions, type OutgoingHttpHeaders } from 'http';
|
|
9
|
+
import { request as httpsRequest } from 'https';
|
|
10
|
+
import { getRemoteOrThrow, updateRemoteLastConnected, type RemoteHost } from './remotes.js';
|
|
11
|
+
import type { ScopeLevel } from './execute.js';
|
|
12
|
+
import type { RoutineRunSummary } from './routine-engine.js';
|
|
13
|
+
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// TYPES
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
export interface RemoteRunOptions {
|
|
19
|
+
/** Package name to execute */
|
|
20
|
+
package: string;
|
|
21
|
+
/** Command within the package */
|
|
22
|
+
command?: string;
|
|
23
|
+
/** Arguments to pass */
|
|
24
|
+
args?: string[];
|
|
25
|
+
/** Environment variables */
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
/** Standard input to pass */
|
|
28
|
+
stdin?: string;
|
|
29
|
+
/** Timeout in milliseconds */
|
|
30
|
+
timeout?: number;
|
|
31
|
+
/** Scope level for execution */
|
|
32
|
+
scope?: ScopeLevel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RemoteRunResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
exitCode: number;
|
|
38
|
+
stdout?: string;
|
|
39
|
+
stderr?: string;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
error?: {
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
details?: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RemoteHealthResult {
|
|
49
|
+
status: 'ok';
|
|
50
|
+
hostname: string;
|
|
51
|
+
version: string;
|
|
52
|
+
uptime: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RemotePackageInfo {
|
|
56
|
+
name: string;
|
|
57
|
+
version: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
commands?: Record<string, { description: string }>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RemotePackageList {
|
|
63
|
+
packages: Array<{
|
|
64
|
+
name: string;
|
|
65
|
+
version: string;
|
|
66
|
+
path: string;
|
|
67
|
+
source: 'local' | 'registry';
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
// ERRORS
|
|
73
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
74
|
+
|
|
75
|
+
export class RemoteConnectionError extends Error {
|
|
76
|
+
constructor(
|
|
77
|
+
public remoteName: string,
|
|
78
|
+
public url: string,
|
|
79
|
+
message: string
|
|
80
|
+
) {
|
|
81
|
+
super(`Failed to connect to remote "${remoteName}" at ${url}: ${message}`);
|
|
82
|
+
this.name = 'RemoteConnectionError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class RemoteApiError extends Error {
|
|
87
|
+
constructor(
|
|
88
|
+
public remoteName: string,
|
|
89
|
+
public statusCode: number,
|
|
90
|
+
public code: string,
|
|
91
|
+
message: string,
|
|
92
|
+
public details?: Record<string, unknown>
|
|
93
|
+
) {
|
|
94
|
+
super(`Remote "${remoteName}" error [${code}]: ${message}`);
|
|
95
|
+
this.name = 'RemoteApiError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
// HTTP CLIENT
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
|
|
103
|
+
interface HttpResponse {
|
|
104
|
+
statusCode: number;
|
|
105
|
+
body: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeRequest(
|
|
109
|
+
url: URL,
|
|
110
|
+
method: string,
|
|
111
|
+
headers: Record<string, string>,
|
|
112
|
+
body?: string,
|
|
113
|
+
timeoutMs: number = 30000
|
|
114
|
+
): Promise<HttpResponse> {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const isHttps = url.protocol === 'https:';
|
|
117
|
+
const requestFn = isHttps ? httpsRequest : httpRequest;
|
|
118
|
+
|
|
119
|
+
const reqHeaders: OutgoingHttpHeaders = {
|
|
120
|
+
...headers,
|
|
121
|
+
'Content-Type': 'application/json'
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (body) {
|
|
125
|
+
reqHeaders['Content-Length'] = Buffer.byteLength(body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const options: RequestOptions = {
|
|
129
|
+
method,
|
|
130
|
+
hostname: url.hostname,
|
|
131
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
132
|
+
path: url.pathname + url.search,
|
|
133
|
+
headers: reqHeaders,
|
|
134
|
+
timeout: timeoutMs
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const req = requestFn(options, (res) => {
|
|
138
|
+
const chunks: Buffer[] = [];
|
|
139
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
140
|
+
res.on('end', () => {
|
|
141
|
+
resolve({
|
|
142
|
+
statusCode: res.statusCode ?? 500,
|
|
143
|
+
body: Buffer.concat(chunks).toString('utf-8')
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
req.on('error', (err) => {
|
|
149
|
+
reject(err);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
req.on('timeout', () => {
|
|
153
|
+
req.destroy();
|
|
154
|
+
reject(new Error('Request timeout'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (body) {
|
|
158
|
+
req.write(body);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
req.end();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildHeaders(remote: RemoteHost): Record<string, string> {
|
|
166
|
+
const headers: Record<string, string> = {
|
|
167
|
+
'Accept': 'application/json'
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (remote.apiKey) {
|
|
171
|
+
headers['X-API-Key'] = remote.apiKey;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return headers;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
|
+
// CLIENT FUNCTIONS
|
|
179
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check health of a remote service
|
|
183
|
+
*/
|
|
184
|
+
export async function remoteHealth(remoteName: string): Promise<RemoteHealthResult> {
|
|
185
|
+
const remote = getRemoteOrThrow(remoteName);
|
|
186
|
+
const url = new URL('/health', remote.url);
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const response = await makeRequest(url, 'GET', buildHeaders(remote));
|
|
190
|
+
|
|
191
|
+
if (response.statusCode !== 200) {
|
|
192
|
+
const error = JSON.parse(response.body)?.error;
|
|
193
|
+
throw new RemoteApiError(
|
|
194
|
+
remoteName,
|
|
195
|
+
response.statusCode,
|
|
196
|
+
error?.code ?? 'API_ERROR',
|
|
197
|
+
error?.message ?? 'Unknown error',
|
|
198
|
+
error?.details
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const data = JSON.parse(response.body) as RemoteHealthResult;
|
|
203
|
+
updateRemoteLastConnected(remoteName);
|
|
204
|
+
|
|
205
|
+
return data;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err instanceof RemoteApiError) throw err;
|
|
208
|
+
throw new RemoteConnectionError(
|
|
209
|
+
remoteName,
|
|
210
|
+
remote.url,
|
|
211
|
+
err instanceof Error ? err.message : String(err)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* List packages on a remote
|
|
218
|
+
*/
|
|
219
|
+
export async function remoteListPackages(remoteName: string): Promise<RemotePackageList> {
|
|
220
|
+
const remote = getRemoteOrThrow(remoteName);
|
|
221
|
+
const url = new URL('/packages', remote.url);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const response = await makeRequest(url, 'GET', buildHeaders(remote));
|
|
225
|
+
|
|
226
|
+
if (response.statusCode !== 200) {
|
|
227
|
+
const error = JSON.parse(response.body)?.error;
|
|
228
|
+
throw new RemoteApiError(
|
|
229
|
+
remoteName,
|
|
230
|
+
response.statusCode,
|
|
231
|
+
error?.code ?? 'API_ERROR',
|
|
232
|
+
error?.message ?? 'Unknown error',
|
|
233
|
+
error?.details
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
updateRemoteLastConnected(remoteName);
|
|
238
|
+
return JSON.parse(response.body) as RemotePackageList;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err instanceof RemoteApiError) throw err;
|
|
241
|
+
throw new RemoteConnectionError(
|
|
242
|
+
remoteName,
|
|
243
|
+
remote.url,
|
|
244
|
+
err instanceof Error ? err.message : String(err)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get package info from a remote
|
|
251
|
+
*/
|
|
252
|
+
export async function remotePackageInfo(remoteName: string, packageName: string): Promise<RemotePackageInfo | null> {
|
|
253
|
+
const remote = getRemoteOrThrow(remoteName);
|
|
254
|
+
const url = new URL(`/packages/${encodeURIComponent(packageName)}`, remote.url);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const response = await makeRequest(url, 'GET', buildHeaders(remote));
|
|
258
|
+
|
|
259
|
+
if (response.statusCode === 404) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (response.statusCode !== 200) {
|
|
264
|
+
const error = JSON.parse(response.body)?.error;
|
|
265
|
+
throw new RemoteApiError(
|
|
266
|
+
remoteName,
|
|
267
|
+
response.statusCode,
|
|
268
|
+
error?.code ?? 'API_ERROR',
|
|
269
|
+
error?.message ?? 'Unknown error',
|
|
270
|
+
error?.details
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updateRemoteLastConnected(remoteName);
|
|
275
|
+
return JSON.parse(response.body) as RemotePackageInfo;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof RemoteApiError) throw err;
|
|
278
|
+
throw new RemoteConnectionError(
|
|
279
|
+
remoteName,
|
|
280
|
+
remote.url,
|
|
281
|
+
err instanceof Error ? err.message : String(err)
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run a tool on a remote
|
|
288
|
+
*/
|
|
289
|
+
export async function remoteRunTool(remoteName: string, options: RemoteRunOptions): Promise<RemoteRunResult> {
|
|
290
|
+
const remote = getRemoteOrThrow(remoteName);
|
|
291
|
+
const url = new URL('/run', remote.url);
|
|
292
|
+
|
|
293
|
+
const body = JSON.stringify({
|
|
294
|
+
package: options.package,
|
|
295
|
+
command: options.command,
|
|
296
|
+
args: options.args,
|
|
297
|
+
env: options.env,
|
|
298
|
+
stdin: options.stdin,
|
|
299
|
+
timeout: options.timeout,
|
|
300
|
+
scope: options.scope
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Use longer timeout for execution (tool timeout + network overhead)
|
|
304
|
+
const requestTimeout = (options.timeout ?? 30000) + 10000;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const response = await makeRequest(url, 'POST', buildHeaders(remote), body, requestTimeout);
|
|
308
|
+
|
|
309
|
+
const data = JSON.parse(response.body);
|
|
310
|
+
|
|
311
|
+
// Check for API-level error
|
|
312
|
+
if (data.error && response.statusCode >= 400 && response.statusCode !== 500) {
|
|
313
|
+
throw new RemoteApiError(
|
|
314
|
+
remoteName,
|
|
315
|
+
response.statusCode,
|
|
316
|
+
data.error.code ?? 'API_ERROR',
|
|
317
|
+
data.error.message ?? 'Unknown error',
|
|
318
|
+
data.error.details
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
updateRemoteLastConnected(remoteName);
|
|
323
|
+
|
|
324
|
+
// Return the run result (may indicate success: false for tool failure)
|
|
325
|
+
return data as RemoteRunResult;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err instanceof RemoteApiError) throw err;
|
|
328
|
+
throw new RemoteConnectionError(
|
|
329
|
+
remoteName,
|
|
330
|
+
remote.url,
|
|
331
|
+
err instanceof Error ? err.message : String(err)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Run a routine on a remote
|
|
338
|
+
*/
|
|
339
|
+
export async function remoteRunRoutine(
|
|
340
|
+
remoteName: string,
|
|
341
|
+
routineName: string,
|
|
342
|
+
vars?: Record<string, string>
|
|
343
|
+
): Promise<RoutineRunSummary> {
|
|
344
|
+
const remote = getRemoteOrThrow(remoteName);
|
|
345
|
+
const url = new URL(`/routines/${encodeURIComponent(routineName)}/run`, remote.url);
|
|
346
|
+
|
|
347
|
+
const body = vars ? JSON.stringify({ vars }) : '{}';
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const response = await makeRequest(url, 'POST', buildHeaders(remote), body, 300000); // 5 min timeout for routines
|
|
351
|
+
|
|
352
|
+
if (response.statusCode === 404) {
|
|
353
|
+
throw new RemoteApiError(
|
|
354
|
+
remoteName,
|
|
355
|
+
404,
|
|
356
|
+
'NOT_FOUND',
|
|
357
|
+
`Routine not found: ${routineName}`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const data = JSON.parse(response.body);
|
|
362
|
+
|
|
363
|
+
if (data.error && response.statusCode >= 400) {
|
|
364
|
+
throw new RemoteApiError(
|
|
365
|
+
remoteName,
|
|
366
|
+
response.statusCode,
|
|
367
|
+
data.error.code ?? 'API_ERROR',
|
|
368
|
+
data.error.message ?? 'Unknown error',
|
|
369
|
+
data.error.details
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
updateRemoteLastConnected(remoteName);
|
|
374
|
+
return data as RoutineRunSummary;
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof RemoteApiError) throw err;
|
|
377
|
+
throw new RemoteConnectionError(
|
|
378
|
+
remoteName,
|
|
379
|
+
remote.url,
|
|
380
|
+
err instanceof Error ? err.message : String(err)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Test connection to a remote
|
|
387
|
+
*/
|
|
388
|
+
export async function testRemoteConnection(remoteName: string): Promise<{ success: boolean; message: string; details?: Record<string, unknown> }> {
|
|
389
|
+
try {
|
|
390
|
+
const health = await remoteHealth(remoteName);
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
message: `Connected to ${health.hostname}`,
|
|
394
|
+
details: {
|
|
395
|
+
hostname: health.hostname,
|
|
396
|
+
version: health.version,
|
|
397
|
+
uptime: health.uptime
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err instanceof RemoteConnectionError) {
|
|
402
|
+
return {
|
|
403
|
+
success: false,
|
|
404
|
+
message: err.message
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (err instanceof RemoteApiError) {
|
|
408
|
+
return {
|
|
409
|
+
success: false,
|
|
410
|
+
message: `${err.code}: ${err.message}`,
|
|
411
|
+
details: err.details
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
success: false,
|
|
416
|
+
message: err instanceof Error ? err.message : String(err)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|