Skip to content

Commit ae86f06

Browse files
committed
🤖 Make exec() async to properly check workdir exists
- Changed Runtime.exec() interface to return Promise<ExecStream> - Updated LocalRuntime.exec() to await fsPromises.access() to check workdir - Updated SSHRuntime.exec() to be async - Updated all callers to await exec() (SSHRuntime, bash.ts, helpers.ts, tests) - Wrapped exec() calls in ReadableStream/WritableStream callbacks where needed This prevents confusing ENOENT errors when the working directory doesn't exist. The check is async and doesn't block unnecessarily.
1 parent 8250712 commit ae86f06

File tree

6 files changed

+45
-32
lines changed

6 files changed

+45
-32
lines changed

src/runtime/LocalRuntime.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,21 @@ export class LocalRuntime implements Runtime {
3333
this.workdir = workdir;
3434
}
3535

36-
exec(command: string, options: ExecOptions): ExecStream {
36+
async exec(command: string, options: ExecOptions): Promise<ExecStream> {
3737
const startTime = performance.now();
3838

3939
// Determine working directory
4040
const cwd = options.cwd ?? this.workdir;
4141

42-
// Verify working directory exists before spawning
43-
// If it doesn't exist, spawn will fail with ENOENT which is confusing
44-
if (!fs.existsSync(cwd)) {
42+
// Check if working directory exists before spawning
43+
// This prevents confusing ENOENT errors from spawn()
44+
try {
45+
await fsPromises.access(cwd);
46+
} catch (err) {
4547
throw new RuntimeErrorClass(
4648
`Working directory does not exist: ${cwd}`,
4749
"exec",
48-
new Error(`ENOENT: no such file or directory, stat '${cwd}'`)
50+
err instanceof Error ? err : undefined
4951
);
5052
}
5153

src/runtime/Runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ export interface Runtime {
134134
* Execute a bash command with streaming I/O
135135
* @param command The bash script to execute
136136
* @param options Execution options (cwd, env, timeout, etc.)
137-
* @returns Streaming handles for stdin/stdout/stderr and completion promises
137+
* @returns Promise that resolves to streaming handles for stdin/stdout/stderr and completion promises
138138
* @throws RuntimeError if execution fails in an unrecoverable way
139139
*/
140-
exec(command: string, options: ExecOptions): ExecStream;
140+
exec(command: string, options: ExecOptions): Promise<ExecStream>;
141141

142142
/**
143143
* Read file contents as a stream

src/runtime/SSHRuntime.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export class SSHRuntime implements Runtime {
6868
/**
6969
* Execute command over SSH with streaming I/O
7070
*/
71-
exec(command: string, options: ExecOptions): ExecStream {
71+
async exec(command: string, options: ExecOptions): Promise<ExecStream> {
7272
const startTime = performance.now();
7373

7474
// Build command parts
@@ -184,15 +184,15 @@ export class SSHRuntime implements Runtime {
184184
* Read file contents over SSH as a stream
185185
*/
186186
readFile(path: string): ReadableStream<Uint8Array> {
187-
const stream = this.exec(`cat ${shescape.quote(path)}`, {
188-
cwd: this.config.workdir,
189-
timeout: 300, // 5 minutes - reasonable for large files
190-
});
191-
192-
// Return stdout, but wrap to handle errors from exit code
187+
// Return stdout, but wrap to handle errors from exec() and exit code
193188
return new ReadableStream<Uint8Array>({
194-
async start(controller: ReadableStreamDefaultController<Uint8Array>) {
189+
start: async (controller: ReadableStreamDefaultController<Uint8Array>) => {
195190
try {
191+
const stream = await this.exec(`cat ${shescape.quote(path)}`, {
192+
cwd: this.config.workdir,
193+
timeout: 300, // 5 minutes - reasonable for large files
194+
});
195+
196196
const reader = stream.stdout.getReader();
197197
const exitCode = stream.exitCode;
198198

@@ -237,22 +237,32 @@ export class SSHRuntime implements Runtime {
237237
// Use shescape.quote for safe path escaping
238238
const writeCommand = `mkdir -p $(dirname ${shescape.quote(path)}) && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(path)}`;
239239

240-
const stream = this.exec(writeCommand, {
241-
cwd: this.config.workdir,
242-
timeout: 300, // 5 minutes - reasonable for large files
243-
});
240+
// Need to get the exec stream in async callbacks
241+
let execPromise: Promise<ExecStream> | null = null;
242+
243+
const getExecStream = () => {
244+
if (!execPromise) {
245+
execPromise = this.exec(writeCommand, {
246+
cwd: this.config.workdir,
247+
timeout: 300, // 5 minutes - reasonable for large files
248+
});
249+
}
250+
return execPromise;
251+
};
244252

245253
// Wrap stdin to handle errors from exit code
246254
return new WritableStream<Uint8Array>({
247-
async write(chunk: Uint8Array) {
255+
write: async (chunk: Uint8Array) => {
256+
const stream = await getExecStream();
248257
const writer = stream.stdin.getWriter();
249258
try {
250259
await writer.write(chunk);
251260
} finally {
252261
writer.releaseLock();
253262
}
254263
},
255-
async close() {
264+
close: async () => {
265+
const stream = await getExecStream();
256266
// Close stdin and wait for command to complete
257267
await stream.stdin.close();
258268
const exitCode = await stream.exitCode;
@@ -262,7 +272,8 @@ export class SSHRuntime implements Runtime {
262272
throw new RuntimeErrorClass(`Failed to write file ${path}: ${stderr}`, "file_io");
263273
}
264274
},
265-
async abort(reason?: unknown) {
275+
abort: async (reason?: unknown) => {
276+
const stream = await getExecStream();
266277
await stream.stdin.abort();
267278
throw new RuntimeErrorClass(`Failed to write file ${path}: ${String(reason)}`, "file_io");
268279
},
@@ -275,7 +286,7 @@ export class SSHRuntime implements Runtime {
275286
async stat(path: string): Promise<FileStat> {
276287
// Use stat with format string to get: size, mtime, type
277288
// %s = size, %Y = mtime (seconds since epoch), %F = file type
278-
const stream = this.exec(`stat -c '%s %Y %F' ${shescape.quote(path)}`, {
289+
const stream = await this.exec(`stat -c '%s %Y %F' ${shescape.quote(path)}`, {
279290
cwd: this.config.workdir,
280291
timeout: 10, // 10 seconds - stat should be fast
281292
});
@@ -397,7 +408,7 @@ export class SSHRuntime implements Runtime {
397408
// git doesn't expand tilde when it's quoted, so we need to expand it ourselves
398409
const cloneDestPath = expandTildeForSSH(this.config.workdir);
399410

400-
const cloneStream = this.exec(`git clone --quiet ${bundleTempPath} ${cloneDestPath}`, {
411+
const cloneStream = await this.exec(`git clone --quiet ${bundleTempPath} ${cloneDestPath}`, {
401412
cwd: "~",
402413
timeout: 300, // 5 minutes for clone
403414
});
@@ -414,7 +425,7 @@ export class SSHRuntime implements Runtime {
414425

415426
// Step 3: Remove bundle file
416427
initLogger.logStep(`Cleaning up bundle file...`);
417-
const rmStream = this.exec(`rm ${bundleTempPath}`, {
428+
const rmStream = await this.exec(`rm ${bundleTempPath}`, {
418429
cwd: "~",
419430
timeout: 10,
420431
});
@@ -428,7 +439,7 @@ export class SSHRuntime implements Runtime {
428439
} catch (error) {
429440
// Try to clean up bundle file on error
430441
try {
431-
const rmStream = this.exec(`rm -f ${bundleTempPath}`, {
442+
const rmStream = await this.exec(`rm -f ${bundleTempPath}`, {
432443
cwd: "~",
433444
timeout: 10,
434445
});
@@ -461,7 +472,7 @@ export class SSHRuntime implements Runtime {
461472

462473
// Run hook remotely and stream output
463474
// No timeout - user init hooks can be arbitrarily long
464-
const hookStream = this.exec(hookCommand, {
475+
const hookStream = await this.exec(hookCommand, {
465476
cwd: this.config.workdir,
466477
timeout: 3600, // 1 hour - generous timeout for init hooks
467478
});
@@ -543,7 +554,7 @@ export class SSHRuntime implements Runtime {
543554
}
544555
}
545556

546-
const mkdirStream = this.exec(parentDirCommand, {
557+
const mkdirStream = await this.exec(parentDirCommand, {
547558
cwd: "/tmp",
548559
timeout: 10,
549560
});
@@ -602,7 +613,7 @@ export class SSHRuntime implements Runtime {
602613
initLogger.logStep(`Checking out branch: ${branchName}`);
603614
const checkoutCmd = `(git checkout ${JSON.stringify(branchName)} 2>/dev/null || git checkout -b ${JSON.stringify(branchName)} HEAD)`;
604615

605-
const checkoutStream = this.exec(checkoutCmd, {
616+
const checkoutStream = await this.exec(checkoutCmd, {
606617
cwd: this.config.workdir,
607618
timeout: 300, // 5 minutes for git checkout (can be slow on large repos)
608619
});

src/services/tools/bash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
102102
// Execute using runtime interface (works for both local and SSH)
103103
// The runtime handles bash wrapping and niceness internally
104104
// Don't pass cwd - let runtime use its workdir (correct path for local or remote)
105-
const execStream = config.runtime.exec(script, {
105+
const execStream = await config.runtime.exec(script, {
106106
env: config.secrets,
107107
timeout: effectiveTimeout,
108108
niceness: config.niceness,

src/utils/runtime/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function execBuffered(
2727
command: string,
2828
options: ExecOptions & { stdin?: string }
2929
): Promise<ExecResult> {
30-
const stream = runtime.exec(command, options);
30+
const stream = await runtime.exec(command, options);
3131

3232
// Write stdin if provided
3333
if (options.stdin !== undefined) {

tests/runtime/test-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class TestWorkspace {
6767
const workspacePath = `/home/testuser/workspace/${testId}`;
6868

6969
// Create directory on remote
70-
const stream = runtime.exec(`mkdir -p ${workspacePath}`, {
70+
const stream = await runtime.exec(`mkdir -p ${workspacePath}`, {
7171
cwd: "/home/testuser",
7272
timeout: 30,
7373
});

0 commit comments

Comments
 (0)