@ruifung/codemode-bridge 1.0.3-1
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 +202 -0
- package/README.md +378 -0
- package/dist/cli/commands.d.ts +70 -0
- package/dist/cli/commands.js +436 -0
- package/dist/cli/config-manager.d.ts +53 -0
- package/dist/cli/config-manager.js +142 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +165 -0
- package/dist/executor/container-executor.d.ts +81 -0
- package/dist/executor/container-executor.js +351 -0
- package/dist/executor/executor-test-suite.d.ts +22 -0
- package/dist/executor/executor-test-suite.js +395 -0
- package/dist/executor/isolated-vm-executor.d.ts +78 -0
- package/dist/executor/isolated-vm-executor.js +368 -0
- package/dist/executor/vm2-executor.d.ts +21 -0
- package/dist/executor/vm2-executor.js +109 -0
- package/dist/executor/wrap-code.d.ts +52 -0
- package/dist/executor/wrap-code.js +80 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/mcp/config.d.ts +44 -0
- package/dist/mcp/config.js +102 -0
- package/dist/mcp/e2e-bridge-test-suite.d.ts +28 -0
- package/dist/mcp/e2e-bridge-test-suite.js +429 -0
- package/dist/mcp/executor.d.ts +31 -0
- package/dist/mcp/executor.js +121 -0
- package/dist/mcp/mcp-adapter.d.ts +12 -0
- package/dist/mcp/mcp-adapter.js +49 -0
- package/dist/mcp/mcp-client.d.ts +85 -0
- package/dist/mcp/mcp-client.js +441 -0
- package/dist/mcp/oauth-handler.d.ts +33 -0
- package/dist/mcp/oauth-handler.js +138 -0
- package/dist/mcp/server.d.ts +25 -0
- package/dist/mcp/server.js +322 -0
- package/dist/mcp/token-persistence.d.ts +57 -0
- package/dist/mcp/token-persistence.js +131 -0
- package/dist/utils/logger.d.ts +44 -0
- package/dist/utils/logger.js +123 -0
- package/package.json +56 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Code Mode Bridge CLI
|
|
4
|
+
*
|
|
5
|
+
* Main entry point for the command-line interface
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* codemode-bridge run [options] - Start the bridge server
|
|
9
|
+
* codemode-bridge config list [options] - List configured servers
|
|
10
|
+
* codemode-bridge config show <name> - Show a server configuration
|
|
11
|
+
* codemode-bridge config add <name> - Add a new server
|
|
12
|
+
* codemode-bridge config remove <name> - Remove a server
|
|
13
|
+
* codemode-bridge config edit <name> - Edit a server
|
|
14
|
+
* codemode-bridge config info - Show config file information
|
|
15
|
+
* codemode-bridge auth list [options] - List OAuth-enabled servers and status
|
|
16
|
+
* codemode-bridge auth login <name> - Prepare to login to an OAuth server
|
|
17
|
+
* codemode-bridge auth logout <name> - Logout from an OAuth server
|
|
18
|
+
*/
|
|
19
|
+
import { Command } from "commander";
|
|
20
|
+
import { runServer, listServersCommand, showServerCommand, addServerCommand, removeServerCommand, editServerCommand, configInfoCommand, authLoginCommand, authLogoutCommand, authListCommand, } from "./commands.js";
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
|
|
23
|
+
const program = new Command();
|
|
24
|
+
program.name("codemode-bridge").description("Code Mode Bridge CLI").version(pkg.version);
|
|
25
|
+
// Main 'run' command
|
|
26
|
+
program
|
|
27
|
+
.command("run")
|
|
28
|
+
.description("Start the bridge MCP server (default command)")
|
|
29
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file (default: ~/.config/codemode-bridge/mcp.json)")
|
|
30
|
+
.option("-s, --servers <names>", "Comma-separated list of servers to connect to")
|
|
31
|
+
.option("-d, --debug", "Enable debug logging")
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
const servers = options.servers ? options.servers.split(",").map((s) => s.trim()) : undefined;
|
|
34
|
+
await runServer(options.config, servers, options.debug);
|
|
35
|
+
});
|
|
36
|
+
// Config command group
|
|
37
|
+
const config = program.command("config").description("Manage bridge configuration");
|
|
38
|
+
config
|
|
39
|
+
.command("list")
|
|
40
|
+
.description("List all configured servers")
|
|
41
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
42
|
+
.action((options) => {
|
|
43
|
+
listServersCommand(options.config);
|
|
44
|
+
});
|
|
45
|
+
config
|
|
46
|
+
.command("show <name>")
|
|
47
|
+
.description("Show a server configuration")
|
|
48
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
49
|
+
.action((name, options) => {
|
|
50
|
+
showServerCommand(name, options.config);
|
|
51
|
+
});
|
|
52
|
+
config
|
|
53
|
+
.command("add <name> [commandAndArgs...]")
|
|
54
|
+
.description("Add a new server configuration")
|
|
55
|
+
.requiredOption("-t, --type <type>", "Server type (stdio or http)")
|
|
56
|
+
.option("--url <url>", "Server URL (required for http servers)")
|
|
57
|
+
.option("--env <env...>", 'Environment variables as KEY=VALUE pairs')
|
|
58
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
59
|
+
.action((name, commandAndArgs, options) => {
|
|
60
|
+
let command;
|
|
61
|
+
let args;
|
|
62
|
+
// Parse command and args from positional arguments
|
|
63
|
+
if (commandAndArgs && commandAndArgs.length > 0) {
|
|
64
|
+
command = commandAndArgs[0];
|
|
65
|
+
if (commandAndArgs.length > 1) {
|
|
66
|
+
args = commandAndArgs.slice(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const env = {};
|
|
70
|
+
if (options.env) {
|
|
71
|
+
for (const pair of options.env) {
|
|
72
|
+
const [key, value] = pair.split("=");
|
|
73
|
+
if (key && value) {
|
|
74
|
+
env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
addServerCommand(name, {
|
|
79
|
+
type: options.type,
|
|
80
|
+
command,
|
|
81
|
+
args,
|
|
82
|
+
url: options.url,
|
|
83
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
84
|
+
}, options.config);
|
|
85
|
+
});
|
|
86
|
+
config
|
|
87
|
+
.command("remove <name>")
|
|
88
|
+
.description("Remove a server configuration")
|
|
89
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
90
|
+
.action((name, options) => {
|
|
91
|
+
removeServerCommand(name, options.config);
|
|
92
|
+
});
|
|
93
|
+
config
|
|
94
|
+
.command("edit <name> [commandAndArgs...]")
|
|
95
|
+
.description("Edit a server configuration")
|
|
96
|
+
.option("-t, --type <type>", "Server type (stdio or http)")
|
|
97
|
+
.option("--url <url>", "Server URL (for http servers)")
|
|
98
|
+
.option("--env <env...>", 'Environment variables as KEY=VALUE pairs')
|
|
99
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
100
|
+
.action((name, commandAndArgs, options) => {
|
|
101
|
+
let command;
|
|
102
|
+
let args;
|
|
103
|
+
// Parse command and args from positional arguments
|
|
104
|
+
if (commandAndArgs && commandAndArgs.length > 0) {
|
|
105
|
+
command = commandAndArgs[0];
|
|
106
|
+
if (commandAndArgs.length > 1) {
|
|
107
|
+
args = commandAndArgs.slice(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const env = {};
|
|
111
|
+
if (options.env) {
|
|
112
|
+
for (const pair of options.env) {
|
|
113
|
+
const [key, value] = pair.split("=");
|
|
114
|
+
if (key && value) {
|
|
115
|
+
env[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
editServerCommand(name, {
|
|
120
|
+
type: options.type,
|
|
121
|
+
command,
|
|
122
|
+
args,
|
|
123
|
+
url: options.url,
|
|
124
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
125
|
+
}, options.config);
|
|
126
|
+
});
|
|
127
|
+
config
|
|
128
|
+
.command("info")
|
|
129
|
+
.description("Show configuration file information")
|
|
130
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
131
|
+
.action((options) => {
|
|
132
|
+
configInfoCommand(options.config);
|
|
133
|
+
});
|
|
134
|
+
// Auth command group
|
|
135
|
+
const auth = program.command("auth").description("Manage OAuth authentication");
|
|
136
|
+
auth
|
|
137
|
+
.command("list")
|
|
138
|
+
.description("List all OAuth-enabled servers and their authentication status")
|
|
139
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
140
|
+
.action((options) => {
|
|
141
|
+
authListCommand(options.config);
|
|
142
|
+
});
|
|
143
|
+
auth
|
|
144
|
+
.command("login <server-name>")
|
|
145
|
+
.description("Initiate OAuth login for a server")
|
|
146
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
147
|
+
.action(async (serverName, options) => {
|
|
148
|
+
await authLoginCommand(serverName, options.config);
|
|
149
|
+
});
|
|
150
|
+
auth
|
|
151
|
+
.command("logout <server-name>")
|
|
152
|
+
.description("Logout from an OAuth server (clears all authentication data)")
|
|
153
|
+
.option("-c, --config <path>", "Path to mcp.json configuration file")
|
|
154
|
+
.action((serverName, options) => {
|
|
155
|
+
authLogoutCommand(serverName, options.config);
|
|
156
|
+
});
|
|
157
|
+
// Default command: run if no command specified
|
|
158
|
+
program.action(async () => {
|
|
159
|
+
// If no command is specified, run the bridge
|
|
160
|
+
const args = process.argv.slice(2);
|
|
161
|
+
if (args.length === 0) {
|
|
162
|
+
await runServer(undefined, undefined, false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
program.parse();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container-based Executor — maximum isolation.
|
|
3
|
+
*
|
|
4
|
+
* Runs LLM-generated code inside a Docker/Podman container with:
|
|
5
|
+
* --network=none (no network access)
|
|
6
|
+
* --read-only (immutable root filesystem, /tmp is writable)
|
|
7
|
+
* --cap-drop=ALL (no Linux capabilities)
|
|
8
|
+
*
|
|
9
|
+
* Communication uses a line-delimited JSON protocol over the container's
|
|
10
|
+
* stdin/stdout. Tool calls are proxied: the runner script inside the
|
|
11
|
+
* container sends tool-call requests, the host dispatches them to the
|
|
12
|
+
* real tool functions, and writes results back.
|
|
13
|
+
*
|
|
14
|
+
* The container is session-scoped — created once and reused across
|
|
15
|
+
* execute() calls. State is cleaned up between calls by the runner.
|
|
16
|
+
*/
|
|
17
|
+
import type { Executor, ExecuteResult } from '@cloudflare/codemode';
|
|
18
|
+
export interface ContainerExecutorOptions {
|
|
19
|
+
/** Execution timeout per call in ms (default 30000) */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
/** Container image (default 'node:22-slim') */
|
|
22
|
+
image?: string;
|
|
23
|
+
/** Container runtime command — 'docker' | 'podman' | auto-detect */
|
|
24
|
+
runtime?: string;
|
|
25
|
+
/** Memory limit (default '256m') */
|
|
26
|
+
memoryLimit?: string;
|
|
27
|
+
/** CPU quota as fractional CPUs (default 1.0) */
|
|
28
|
+
cpuLimit?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare class ContainerExecutor implements Executor {
|
|
31
|
+
private runtime;
|
|
32
|
+
private image;
|
|
33
|
+
private timeout;
|
|
34
|
+
private memoryLimit;
|
|
35
|
+
private cpuLimit;
|
|
36
|
+
private containerId;
|
|
37
|
+
private process;
|
|
38
|
+
private readline;
|
|
39
|
+
private ready;
|
|
40
|
+
/** Resolved when the container sends { type: 'ready' } */
|
|
41
|
+
private readyResolve;
|
|
42
|
+
/** Pending execution — only one at a time */
|
|
43
|
+
private pendingExecution;
|
|
44
|
+
private initPromise;
|
|
45
|
+
constructor(options?: ContainerExecutorOptions);
|
|
46
|
+
/**
|
|
47
|
+
* Start the container and wait for the runner to signal readiness.
|
|
48
|
+
* Called lazily on first execute().
|
|
49
|
+
*/
|
|
50
|
+
private init;
|
|
51
|
+
/**
|
|
52
|
+
* Pull the container image if not already present.
|
|
53
|
+
* Runs synchronously so the image is ready before we start the container.
|
|
54
|
+
*/
|
|
55
|
+
private pullImage;
|
|
56
|
+
private _init;
|
|
57
|
+
/**
|
|
58
|
+
* Handle a line of JSON from the container's stdout.
|
|
59
|
+
*/
|
|
60
|
+
private handleMessage;
|
|
61
|
+
/**
|
|
62
|
+
* Dispatch a tool call from the container to the host-side tool function.
|
|
63
|
+
*/
|
|
64
|
+
private handleToolCall;
|
|
65
|
+
/**
|
|
66
|
+
* Send a message to the container via stdin.
|
|
67
|
+
*/
|
|
68
|
+
private send;
|
|
69
|
+
/**
|
|
70
|
+
* Execute code inside the container.
|
|
71
|
+
*/
|
|
72
|
+
execute(code: string, fns: Record<string, (...args: unknown[]) => Promise<unknown>>): Promise<ExecuteResult>;
|
|
73
|
+
/**
|
|
74
|
+
* Stop and clean up the container.
|
|
75
|
+
*/
|
|
76
|
+
dispose(): void;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Factory function matching the pattern of other executors.
|
|
80
|
+
*/
|
|
81
|
+
export declare function createContainerExecutor(options?: ContainerExecutorOptions): ContainerExecutor;
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container-based Executor — maximum isolation.
|
|
3
|
+
*
|
|
4
|
+
* Runs LLM-generated code inside a Docker/Podman container with:
|
|
5
|
+
* --network=none (no network access)
|
|
6
|
+
* --read-only (immutable root filesystem, /tmp is writable)
|
|
7
|
+
* --cap-drop=ALL (no Linux capabilities)
|
|
8
|
+
*
|
|
9
|
+
* Communication uses a line-delimited JSON protocol over the container's
|
|
10
|
+
* stdin/stdout. Tool calls are proxied: the runner script inside the
|
|
11
|
+
* container sends tool-call requests, the host dispatches them to the
|
|
12
|
+
* real tool functions, and writes results back.
|
|
13
|
+
*
|
|
14
|
+
* The container is session-scoped — created once and reused across
|
|
15
|
+
* execute() calls. State is cleaned up between calls by the runner.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { wrapCode } from './wrap-code.js';
|
|
23
|
+
// ── Runtime detection ───────────────────────────────────────────────
|
|
24
|
+
function detectRuntime(requested) {
|
|
25
|
+
if (requested)
|
|
26
|
+
return requested;
|
|
27
|
+
// Try docker first, then podman
|
|
28
|
+
for (const cmd of ['docker', 'podman']) {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync(cmd, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
31
|
+
return cmd;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// not available
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error('No container runtime found. Install Docker or Podman, ' +
|
|
38
|
+
'or set CONTAINER_RUNTIME environment variable.');
|
|
39
|
+
}
|
|
40
|
+
// ── Resolve runner script path ──────────────────────────────────────
|
|
41
|
+
function getScriptPaths() {
|
|
42
|
+
// Works in both ESM and CJS contexts
|
|
43
|
+
let dir;
|
|
44
|
+
try {
|
|
45
|
+
// ESM
|
|
46
|
+
dir = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// CJS fallback
|
|
50
|
+
dir = __dirname;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
runner: join(dir, 'container-runner.mjs'),
|
|
54
|
+
worker: join(dir, 'container-worker.mjs'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ── ContainerExecutor ───────────────────────────────────────────────
|
|
58
|
+
export class ContainerExecutor {
|
|
59
|
+
constructor(options = {}) {
|
|
60
|
+
this.containerId = null;
|
|
61
|
+
this.process = null;
|
|
62
|
+
this.readline = null;
|
|
63
|
+
this.ready = false;
|
|
64
|
+
/** Resolved when the container sends { type: 'ready' } */
|
|
65
|
+
this.readyResolve = null;
|
|
66
|
+
/** Pending execution — only one at a time */
|
|
67
|
+
this.pendingExecution = null;
|
|
68
|
+
this.initPromise = null;
|
|
69
|
+
this.timeout = options.timeout ?? 30000;
|
|
70
|
+
this.image = options.image ?? 'node:24-slim';
|
|
71
|
+
this.memoryLimit = options.memoryLimit ?? '256m';
|
|
72
|
+
this.cpuLimit = options.cpuLimit ?? 1.0;
|
|
73
|
+
this.runtime = detectRuntime(options.runtime);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Start the container and wait for the runner to signal readiness.
|
|
77
|
+
* Called lazily on first execute().
|
|
78
|
+
*/
|
|
79
|
+
async init() {
|
|
80
|
+
if (this.ready)
|
|
81
|
+
return;
|
|
82
|
+
if (this.initPromise)
|
|
83
|
+
return this.initPromise;
|
|
84
|
+
this.initPromise = this._init();
|
|
85
|
+
return this.initPromise;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pull the container image if not already present.
|
|
89
|
+
* Runs synchronously so the image is ready before we start the container.
|
|
90
|
+
*/
|
|
91
|
+
async pullImage() {
|
|
92
|
+
// Check if image exists locally first
|
|
93
|
+
try {
|
|
94
|
+
execFileSync(this.runtime, ['image', 'inspect', this.image], {
|
|
95
|
+
stdio: 'ignore',
|
|
96
|
+
timeout: 10000,
|
|
97
|
+
});
|
|
98
|
+
return; // image already present
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// image not found locally, pull it
|
|
102
|
+
}
|
|
103
|
+
// Pull the image — this can take a while on first run
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const pull = spawn(this.runtime, ['pull', this.image], {
|
|
106
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
107
|
+
});
|
|
108
|
+
pull.stderr?.on('data', (data) => {
|
|
109
|
+
process.stderr.write(`[container] ${data.toString()}`);
|
|
110
|
+
});
|
|
111
|
+
pull.stdout?.on('data', (data) => {
|
|
112
|
+
process.stderr.write(`[container] ${data.toString()}`);
|
|
113
|
+
});
|
|
114
|
+
pull.on('close', (code) => {
|
|
115
|
+
if (code === 0) {
|
|
116
|
+
resolve();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
reject(new Error(`Failed to pull image '${this.image}' (exit code ${code})`));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
pull.on('error', (err) => {
|
|
123
|
+
reject(new Error(`Failed to pull image '${this.image}': ${err.message}`));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async _init() {
|
|
128
|
+
const scripts = getScriptPaths();
|
|
129
|
+
// Pull the image first (no-op if already present)
|
|
130
|
+
await this.pullImage();
|
|
131
|
+
// Start a long-lived container with the runner + worker scripts
|
|
132
|
+
const suffix = randomBytes(4).toString('hex');
|
|
133
|
+
const containerName = `codemode-executor-${suffix}`;
|
|
134
|
+
const args = [
|
|
135
|
+
'run',
|
|
136
|
+
'--rm',
|
|
137
|
+
'-i', // keep stdin open
|
|
138
|
+
'--name', containerName, // identifiable container name
|
|
139
|
+
'--network=none', // no network
|
|
140
|
+
'--read-only', // immutable rootfs
|
|
141
|
+
'--tmpfs', '/tmp:rw,noexec,nosuid,size=64m', // writable /tmp
|
|
142
|
+
'--cap-drop=ALL', // drop all capabilities
|
|
143
|
+
'--user', 'node', // run as non-root user
|
|
144
|
+
'--memory', this.memoryLimit,
|
|
145
|
+
`--cpus=${this.cpuLimit}`,
|
|
146
|
+
'--pids-limit=64', // limit process spawning
|
|
147
|
+
'-v', `${scripts.runner}:/app/container-runner.mjs:ro`, // mount runner script
|
|
148
|
+
'-v', `${scripts.worker}:/app/container-worker.mjs:ro`, // mount worker script
|
|
149
|
+
'-w', '/app',
|
|
150
|
+
this.image,
|
|
151
|
+
'node', '/app/container-runner.mjs',
|
|
152
|
+
];
|
|
153
|
+
this.process = spawn(this.runtime, args, {
|
|
154
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
155
|
+
});
|
|
156
|
+
// Forward stderr for debugging
|
|
157
|
+
this.process.stderr?.on('data', (data) => {
|
|
158
|
+
process.stderr.write(`[container] ${data.toString()}`);
|
|
159
|
+
});
|
|
160
|
+
this.process.on('exit', (code) => {
|
|
161
|
+
this.ready = false;
|
|
162
|
+
this.containerId = null;
|
|
163
|
+
// Reject any pending execution
|
|
164
|
+
if (this.pendingExecution) {
|
|
165
|
+
clearTimeout(this.pendingExecution.timeoutHandle);
|
|
166
|
+
this.pendingExecution.reject(new Error(`Container exited unexpectedly with code ${code}`));
|
|
167
|
+
this.pendingExecution = null;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Set up line reader on stdout
|
|
171
|
+
this.readline = createInterface({
|
|
172
|
+
input: this.process.stdout,
|
|
173
|
+
terminal: false,
|
|
174
|
+
});
|
|
175
|
+
this.readline.on('line', (line) => this.handleMessage(line));
|
|
176
|
+
// Wait for the "ready" message (generous timeout to allow image pull)
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
const timeout = setTimeout(() => {
|
|
179
|
+
reject(new Error('Container failed to become ready within 120s'));
|
|
180
|
+
}, 120000);
|
|
181
|
+
// Store resolve so handleMessage can call it when ready arrives
|
|
182
|
+
this.readyResolve = () => {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
this.ready = true;
|
|
185
|
+
this.readyResolve = null;
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
// Handle startup failure
|
|
189
|
+
this.process.on('error', (err) => {
|
|
190
|
+
clearTimeout(timeout);
|
|
191
|
+
this.readyResolve = null;
|
|
192
|
+
reject(new Error(`Failed to start container: ${err.message}`));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Handle a line of JSON from the container's stdout.
|
|
198
|
+
*/
|
|
199
|
+
handleMessage(line) {
|
|
200
|
+
if (!line.trim())
|
|
201
|
+
return;
|
|
202
|
+
let msg;
|
|
203
|
+
try {
|
|
204
|
+
msg = JSON.parse(line);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
process.stderr.write(`[container-executor] bad JSON from container: ${line}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
switch (msg.type) {
|
|
211
|
+
case 'tool-call':
|
|
212
|
+
this.handleToolCall(msg);
|
|
213
|
+
break;
|
|
214
|
+
case 'result':
|
|
215
|
+
if (this.pendingExecution && this.pendingExecution.id === msg.id) {
|
|
216
|
+
clearTimeout(this.pendingExecution.timeoutHandle);
|
|
217
|
+
this.pendingExecution.resolve({
|
|
218
|
+
result: msg.result,
|
|
219
|
+
logs: msg.logs,
|
|
220
|
+
});
|
|
221
|
+
this.pendingExecution = null;
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
case 'error':
|
|
225
|
+
if (this.pendingExecution && this.pendingExecution.id === msg.id) {
|
|
226
|
+
clearTimeout(this.pendingExecution.timeoutHandle);
|
|
227
|
+
this.pendingExecution.resolve({
|
|
228
|
+
result: undefined,
|
|
229
|
+
error: msg.error,
|
|
230
|
+
logs: msg.logs,
|
|
231
|
+
});
|
|
232
|
+
this.pendingExecution = null;
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
case 'ready':
|
|
236
|
+
if (this.readyResolve) {
|
|
237
|
+
this.readyResolve();
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Dispatch a tool call from the container to the host-side tool function.
|
|
244
|
+
*/
|
|
245
|
+
async handleToolCall(msg) {
|
|
246
|
+
if (!this.pendingExecution) {
|
|
247
|
+
this.send({ type: 'tool-error', id: msg.id, error: 'No active execution context' });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const fns = this.pendingExecution.fns;
|
|
251
|
+
const fn = fns[msg.name];
|
|
252
|
+
if (!fn) {
|
|
253
|
+
this.send({
|
|
254
|
+
type: 'tool-error',
|
|
255
|
+
id: msg.id,
|
|
256
|
+
error: `Tool '${msg.name}' not found. Available tools: ${Object.keys(fns).join(', ')}`,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const args = msg.args;
|
|
262
|
+
const result = await fn(...(Array.isArray(args) ? args : [args]));
|
|
263
|
+
this.send({ type: 'tool-result', id: msg.id, result });
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
this.send({
|
|
267
|
+
type: 'tool-error',
|
|
268
|
+
id: msg.id,
|
|
269
|
+
error: err instanceof Error ? err.message : String(err),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Send a message to the container via stdin.
|
|
275
|
+
*/
|
|
276
|
+
send(msg) {
|
|
277
|
+
if (!this.process?.stdin?.writable) {
|
|
278
|
+
throw new Error('Container stdin is not writable');
|
|
279
|
+
}
|
|
280
|
+
this.process.stdin.write(JSON.stringify(msg) + '\n');
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Execute code inside the container.
|
|
284
|
+
*/
|
|
285
|
+
async execute(code, fns) {
|
|
286
|
+
await this.init();
|
|
287
|
+
if (this.pendingExecution) {
|
|
288
|
+
return {
|
|
289
|
+
result: undefined,
|
|
290
|
+
error: 'Another execution is already in progress',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const id = `exec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
294
|
+
const wrappedCode = wrapCode(code);
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const timeoutHandle = setTimeout(() => {
|
|
297
|
+
if (this.pendingExecution?.id === id) {
|
|
298
|
+
this.pendingExecution = null;
|
|
299
|
+
resolve({
|
|
300
|
+
result: undefined,
|
|
301
|
+
error: `Code execution timeout after ${this.timeout}ms`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}, this.timeout);
|
|
305
|
+
this.pendingExecution = { id, resolve, reject, fns, timeoutHandle };
|
|
306
|
+
this.send({ type: 'execute', id, code: wrappedCode });
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Stop and clean up the container.
|
|
311
|
+
*/
|
|
312
|
+
dispose() {
|
|
313
|
+
if (this.pendingExecution) {
|
|
314
|
+
clearTimeout(this.pendingExecution.timeoutHandle);
|
|
315
|
+
this.pendingExecution.reject(new Error('Executor disposed'));
|
|
316
|
+
this.pendingExecution = null;
|
|
317
|
+
}
|
|
318
|
+
if (this.process?.stdin?.writable) {
|
|
319
|
+
try {
|
|
320
|
+
this.send({ type: 'shutdown' });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// ignore
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (this.readline) {
|
|
327
|
+
this.readline.close();
|
|
328
|
+
this.readline = null;
|
|
329
|
+
}
|
|
330
|
+
if (this.process) {
|
|
331
|
+
this.process.kill('SIGTERM');
|
|
332
|
+
// Force kill after 5 seconds if still alive
|
|
333
|
+
const proc = this.process;
|
|
334
|
+
setTimeout(() => {
|
|
335
|
+
try {
|
|
336
|
+
proc.kill('SIGKILL');
|
|
337
|
+
}
|
|
338
|
+
catch { /* already dead */ }
|
|
339
|
+
}, 5000);
|
|
340
|
+
this.process = null;
|
|
341
|
+
}
|
|
342
|
+
this.ready = false;
|
|
343
|
+
this.initPromise = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Factory function matching the pattern of other executors.
|
|
348
|
+
*/
|
|
349
|
+
export function createContainerExecutor(options) {
|
|
350
|
+
return new ContainerExecutor(options);
|
|
351
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Executor } from '@cloudflare/codemode';
|
|
2
|
+
/**
|
|
3
|
+
* Universal executor test suite
|
|
4
|
+
*
|
|
5
|
+
* This suite can be run against any Executor implementation to verify
|
|
6
|
+
* compliance with the standard interface and behavior expectations.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { createVM2Executor } from './vm2-executor';
|
|
11
|
+
* import { createExecutorTestSuite } from './executor.test';
|
|
12
|
+
*
|
|
13
|
+
* createExecutorTestSuite('vm2', () => createVM2Executor());
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export interface ExecutorTestSuiteOptions {
|
|
17
|
+
/** Test names to skip (exact match against it() description) */
|
|
18
|
+
skipTests?: string[];
|
|
19
|
+
/** Per-test timeout in ms (default: vitest default) */
|
|
20
|
+
testTimeout?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function createExecutorTestSuite(name: string, createExecutor: () => Executor, options?: ExecutorTestSuiteOptions): void;
|