Skip to content

Commit f727584

Browse files
committed
🤖 Add pluggable Runtime abstraction layer
Introduces a Runtime abstraction to decouple command execution and file I/O from specific environments (local, SSH, containers, etc). **Core Components:** - Runtime interface: exec(), readFile(), writeFile(), stat() - LocalRuntime: Node.js implementation using child_process and fs - Web Streams API for all I/O (stdout, stderr, stdin, file streams) - Mandatory timeouts to prevent zombie processes **Tool Integration:** - file_read, file_edit_*, bash tools now accept runtime parameter - Runtime injected via AIService based on workspace configuration - All file operations respect runtime boundaries **Benefits:** - Enables remote development (SSH, containers) - Better testability with mock runtimes - Consistent streaming interface across all tools - Proper resource cleanup with AbortController and timeouts Generated with `cmux`
1 parent 110962b commit f727584

16 files changed

+665
-60
lines changed

src/constants/env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Standard environment variables for non-interactive command execution.
3+
* These prevent tools from blocking on editor/credential prompts.
4+
*/
5+
export const NON_INTERACTIVE_ENV_VARS = {
6+
// Prevent interactive editors from blocking execution
7+
// Critical for git operations like rebase/commit that try to open editors
8+
GIT_EDITOR: "true", // Git-specific editor (highest priority)
9+
GIT_SEQUENCE_EDITOR: "true", // For interactive rebase sequences
10+
EDITOR: "true", // General fallback for non-git commands
11+
VISUAL: "true", // Another common editor environment variable
12+
// Prevent git from prompting for credentials
13+
GIT_TERMINAL_PROMPT: "0", // Disables git credential prompts
14+
} as const;

src/runtime/LocalRuntime.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { spawn } from "child_process";
2+
import * as fs from "fs";
3+
import * as fsPromises from "fs/promises";
4+
import * as path from "path";
5+
import { Readable, Writable } from "stream";
6+
import type {
7+
Runtime,
8+
ExecOptions,
9+
ExecStream,
10+
FileStat,
11+
WorkspaceCreationParams,
12+
WorkspaceCreationResult,
13+
InitLogger,
14+
} from "./Runtime";
15+
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
16+
import { NON_INTERACTIVE_ENV_VARS } from "../constants/env";
17+
import { createWorktree } from "../git";
18+
import { Config } from "../config";
19+
import {
20+
checkInitHookExists,
21+
getInitHookPath,
22+
createLineBufferedLoggers,
23+
} from "./initHook";
24+
25+
/**
26+
* Local runtime implementation that executes commands and file operations
27+
* directly on the host machine using Node.js APIs.
28+
*/
29+
export class LocalRuntime implements Runtime {
30+
private readonly workdir: string;
31+
32+
constructor(workdir: string) {
33+
this.workdir = workdir;
34+
}
35+
36+
exec(command: string, options: ExecOptions): ExecStream {
37+
const startTime = performance.now();
38+
39+
// If niceness is specified, spawn nice directly to avoid escaping issues
40+
const spawnCommand = options.niceness !== undefined ? "nice" : "bash";
41+
const spawnArgs =
42+
options.niceness !== undefined
43+
? ["-n", options.niceness.toString(), "bash", "-c", command]
44+
: ["-c", command];
45+
46+
const childProcess = spawn(spawnCommand, spawnArgs, {
47+
cwd: options.cwd ?? this.workdir,
48+
env: {
49+
...process.env,
50+
...(options.env ?? {}),
51+
...NON_INTERACTIVE_ENV_VARS,
52+
},
53+
stdio: ["pipe", "pipe", "pipe"],
54+
});
55+
56+
// Convert Node.js streams to Web Streams
57+
const stdout = Readable.toWeb(childProcess.stdout) as unknown as ReadableStream<Uint8Array>;
58+
const stderr = Readable.toWeb(childProcess.stderr) as unknown as ReadableStream<Uint8Array>;
59+
const stdin = Writable.toWeb(childProcess.stdin) as unknown as WritableStream<Uint8Array>;
60+
61+
// Create promises for exit code and duration
62+
const exitCode = new Promise<number>((resolve, reject) => {
63+
childProcess.on("close", (code, signal) => {
64+
if (options.abortSignal?.aborted) {
65+
reject(new RuntimeErrorClass("Command execution was aborted", "exec"));
66+
return;
67+
}
68+
if (signal === "SIGTERM" && options.timeout !== undefined) {
69+
reject(
70+
new RuntimeErrorClass(`Command exceeded timeout of ${options.timeout} seconds`, "exec")
71+
);
72+
return;
73+
}
74+
resolve(code ?? (signal ? -1 : 0));
75+
});
76+
77+
childProcess.on("error", (err) => {
78+
reject(new RuntimeErrorClass(`Failed to execute command: ${err.message}`, "exec", err));
79+
});
80+
});
81+
82+
const duration = exitCode.then(() => performance.now() - startTime);
83+
84+
// Handle abort signal
85+
if (options.abortSignal) {
86+
options.abortSignal.addEventListener("abort", () => childProcess.kill());
87+
}
88+
89+
// Handle timeout
90+
if (options.timeout !== undefined) {
91+
setTimeout(() => childProcess.kill(), options.timeout * 1000);
92+
}
93+
94+
return { stdout, stderr, stdin, exitCode, duration };
95+
}
96+
97+
readFile(filePath: string): ReadableStream<Uint8Array> {
98+
const nodeStream = fs.createReadStream(filePath);
99+
100+
// Handle errors by wrapping in a transform
101+
const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
102+
103+
return new ReadableStream<Uint8Array>({
104+
async start(controller: ReadableStreamDefaultController<Uint8Array>) {
105+
try {
106+
const reader = webStream.getReader();
107+
while (true) {
108+
const { done, value } = await reader.read();
109+
if (done) break;
110+
controller.enqueue(value);
111+
}
112+
controller.close();
113+
} catch (err) {
114+
controller.error(
115+
new RuntimeErrorClass(
116+
`Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
117+
"file_io",
118+
err instanceof Error ? err : undefined
119+
)
120+
);
121+
}
122+
},
123+
});
124+
}
125+
126+
writeFile(filePath: string): WritableStream<Uint8Array> {
127+
let tempPath: string;
128+
let writer: WritableStreamDefaultWriter<Uint8Array>;
129+
130+
return new WritableStream<Uint8Array>({
131+
async start() {
132+
// Create parent directories if they don't exist
133+
const parentDir = path.dirname(filePath);
134+
await fsPromises.mkdir(parentDir, { recursive: true });
135+
136+
// Create temp file for atomic write
137+
tempPath = `${filePath}.tmp.${Date.now()}`;
138+
const nodeStream = fs.createWriteStream(tempPath);
139+
const webStream = Writable.toWeb(nodeStream) as WritableStream<Uint8Array>;
140+
writer = webStream.getWriter();
141+
},
142+
async write(chunk: Uint8Array) {
143+
await writer.write(chunk);
144+
},
145+
async close() {
146+
// Close the writer and rename to final location
147+
await writer.close();
148+
try {
149+
await fsPromises.rename(tempPath, filePath);
150+
} catch (err) {
151+
throw new RuntimeErrorClass(
152+
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
153+
"file_io",
154+
err instanceof Error ? err : undefined
155+
);
156+
}
157+
},
158+
async abort(reason?: unknown) {
159+
// Clean up temp file on abort
160+
await writer.abort();
161+
try {
162+
await fsPromises.unlink(tempPath);
163+
} catch {
164+
// Ignore errors cleaning up temp file
165+
}
166+
throw new RuntimeErrorClass(
167+
`Failed to write file ${filePath}: ${String(reason)}`,
168+
"file_io"
169+
);
170+
},
171+
});
172+
}
173+
174+
async stat(filePath: string): Promise<FileStat> {
175+
try {
176+
const stats = await fsPromises.stat(filePath);
177+
return {
178+
size: stats.size,
179+
modifiedTime: stats.mtime,
180+
isDirectory: stats.isDirectory(),
181+
};
182+
} catch (err) {
183+
throw new RuntimeErrorClass(
184+
`Failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
185+
"file_io",
186+
err instanceof Error ? err : undefined
187+
);
188+
}
189+
}
190+
191+
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
192+
const { projectPath, branchName, trunkBranch, workspaceId, initLogger } = params;
193+
194+
// Log creation step
195+
initLogger.logStep("Creating git worktree...");
196+
197+
// Load config to use existing git helpers
198+
const config = new Config();
199+
200+
// Use existing createWorktree helper which handles all the git logic
201+
const result = await createWorktree(config, projectPath, branchName, {
202+
trunkBranch,
203+
workspaceId,
204+
});
205+
206+
// Map WorktreeResult to WorkspaceCreationResult
207+
if (!result.success) {
208+
return { success: false, error: result.error };
209+
}
210+
211+
const workspacePath = result.path!;
212+
initLogger.logStep("Worktree created successfully");
213+
214+
// Run .cmux/init hook if it exists
215+
await this.runInitHook(projectPath, workspacePath, initLogger);
216+
217+
return { success: true, workspacePath };
218+
}
219+
220+
/**
221+
* Run .cmux/init hook if it exists and is executable
222+
*/
223+
private async runInitHook(
224+
projectPath: string,
225+
workspacePath: string,
226+
initLogger: InitLogger
227+
): Promise<void> {
228+
// Check if hook exists and is executable
229+
const hookExists = await checkInitHookExists(projectPath);
230+
if (!hookExists) {
231+
return;
232+
}
233+
234+
const hookPath = getInitHookPath(projectPath);
235+
initLogger.logStep(`Running init hook: ${hookPath}`);
236+
237+
// Create line-buffered loggers
238+
const loggers = createLineBufferedLoggers(initLogger);
239+
240+
return new Promise<void>((resolve) => {
241+
const proc = spawn("bash", ["-c", `"${hookPath}"`], {
242+
cwd: workspacePath,
243+
stdio: ["ignore", "pipe", "pipe"],
244+
});
245+
246+
proc.stdout.on("data", (data: Buffer) => {
247+
loggers.stdout.append(data.toString());
248+
});
249+
250+
proc.stderr.on("data", (data: Buffer) => {
251+
loggers.stderr.append(data.toString());
252+
});
253+
254+
proc.on("close", (code) => {
255+
// Flush any remaining buffered output
256+
loggers.stdout.flush();
257+
loggers.stderr.flush();
258+
259+
initLogger.logComplete(code ?? 0);
260+
resolve();
261+
});
262+
263+
proc.on("error", (err) => {
264+
initLogger.logStderr(`Error running init hook: ${err.message}`);
265+
initLogger.logComplete(-1);
266+
resolve();
267+
});
268+
});
269+
}
270+
}

0 commit comments

Comments
 (0)