Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ecf495c
🤖 Add pluggable runtime abstraction layer
ammar-agent Oct 11, 2025
ffdb45c
🤖 Fix lint: remove unused import
ammar-agent Oct 11, 2025
583ac2a
🤖 Fix prettier formatting
ammar-agent Oct 11, 2025
a67e437
🤖 Fix test and type errors after rebase
ammar-agent Oct 22, 2025
6e340c1
🤖 Fix prettier formatting
ammar-agent Oct 22, 2025
5377207
🤖 Add SSH runtime implementation
ammar-agent Oct 22, 2025
a522bfc
🤖 Integrate runtime config with workspace metadata and AIService
ammar-agent Oct 22, 2025
d746206
🤖 Fix prettier formatting
ammar-agent Oct 22, 2025
94d85c5
🤖 Fix lint errors in SSH runtime
ammar-agent Oct 22, 2025
9b4355b
🤖 Add no-op rebuild script for electron-builder
ammar-agent Oct 22, 2025
bc0bd1f
Extract git env vars to shared constant to avoid duplication
ammar-agent Oct 22, 2025
41b85d9
Remove exists() from Runtime interface, use shared utility
ammar-agent Oct 23, 2025
1f04a6f
Fix rebase conflicts and lockfile
ammar-agent Oct 23, 2025
4d22280
Clean up extra whitespace
ammar-agent Oct 23, 2025
6b38c59
Rewrite SSH runtime to use ssh command instead of ssh2 library
ammar-agent Oct 23, 2025
69811cb
Convert Runtime interface to streaming with convenience helpers
ammar-agent Oct 23, 2025
2ee4a70
Address review feedback: remove isFile from FileStat
ammar-agent Oct 23, 2025
9d11e51
🤖 Add runtime integration tests with Docker SSH server
ammar-agent Oct 23, 2025
daf904f
Remove stray test README, update AGENTS.md to prevent test READMEs
ammar-agent Oct 23, 2025
4c9fac8
🤖 Make timeout mandatory in ExecOptions to prevent zombies
ammar-agent Oct 23, 2025
333b2c5
🤖 Add macOS runtime integration tests to CI
ammar-agent Oct 23, 2025
c8e11ca
🤖 Use depot macOS runners for runtime integration tests
ammar-agent Oct 24, 2025
0d92b7e
🤖 Use depot-macos-15 runner for runtime tests
ammar-agent Oct 24, 2025
1ec9cab
🤖 Install Docker on macOS runners for runtime tests
ammar-agent Oct 24, 2025
9bb3bea
🤖 Remove macOS runtime integration tests from CI
ammar-agent Oct 24, 2025
76002b0
🤖 Add workdir to LocalRuntime for symmetry with SSHRuntime
ammar-agent Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 74 additions & 128 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co

## Documentation Guidelines

**Free-floating markdown docs are not permitted.** Documentation must be organized:
**Free-floating markdown docs are not permitted.** Documentation must be organized. Do not create standalone markdown files in the project root or random locations, even for implementation summaries or planning documents - use the propose_plan tool or inline comments instead.

- **User-facing docs** → `./docs/` directory
- **IMPORTANT**: Read `docs/README.md` first before writing user-facing documentation
Expand All @@ -119,6 +119,7 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co
- Use standard markdown + mermaid diagrams
- **Developer docs** → inline with the code its documenting as comments. Consider them notes as notes to future Assistants to understand the logic more quickly.
**DO NOT** create standalone documentation files in the project root or random locations.
- **Test documentation** → inline comments in test files explaining complex test setup or edge cases, NOT separate README files.

**NEVER create markdown documentation files (README, guides, summaries, etc.) in the project root during feature development unless the user explicitly requests documentation.** Code + tests + inline comments are complete documentation.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"docs:watch": "make docs-watch",
"storybook": "make storybook",
"storybook:build": "make storybook-build",
"test:storybook": "make test-storybook"
"test:storybook": "make test-storybook",
"rebuild": "echo \"No native modules to rebuild\""
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.29",
Expand Down
14 changes: 14 additions & 0 deletions src/constants/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Standard environment variables for non-interactive command execution.
* These prevent tools from blocking on editor/credential prompts.
*/
export const NON_INTERACTIVE_ENV_VARS = {
// Prevent interactive editors from blocking execution
// Critical for git operations like rebase/commit that try to open editors
GIT_EDITOR: "true", // Git-specific editor (highest priority)
GIT_SEQUENCE_EDITOR: "true", // For interactive rebase sequences
EDITOR: "true", // General fallback for non-git commands
VISUAL: "true", // Another common editor environment variable
// Prevent git from prompting for credentials
GIT_TERMINAL_PROMPT: "0", // Disables git credential prompts
} as const;
175 changes: 175 additions & 0 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as fsPromises from "fs/promises";
import * as path from "path";
import { Readable, Writable } from "stream";
import type { Runtime, ExecOptions, ExecStream, FileStat } from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
import { NON_INTERACTIVE_ENV_VARS } from "../constants/env";

/**
* Local runtime implementation that executes commands and file operations
* directly on the host machine using Node.js APIs.
*/
export class LocalRuntime implements Runtime {
private readonly workdir: string;

constructor(workdir: string) {
this.workdir = workdir;
}

exec(command: string, options: ExecOptions): ExecStream {
const startTime = performance.now();

// If niceness is specified, spawn nice directly to avoid escaping issues
const spawnCommand = options.niceness !== undefined ? "nice" : "bash";
const spawnArgs =
options.niceness !== undefined
? ["-n", options.niceness.toString(), "bash", "-c", command]
: ["-c", command];

const childProcess = spawn(spawnCommand, spawnArgs, {
cwd: options.cwd ?? this.workdir,
env: {
...process.env,
...(options.env ?? {}),
...NON_INTERACTIVE_ENV_VARS,
},
stdio: ["pipe", "pipe", "pipe"],
});

// Convert Node.js streams to Web Streams
const stdout = Readable.toWeb(childProcess.stdout) as unknown as ReadableStream<Uint8Array>;
const stderr = Readable.toWeb(childProcess.stderr) as unknown as ReadableStream<Uint8Array>;
const stdin = Writable.toWeb(childProcess.stdin) as unknown as WritableStream<Uint8Array>;

// Create promises for exit code and duration
const exitCode = new Promise<number>((resolve, reject) => {
childProcess.on("close", (code, signal) => {
if (options.abortSignal?.aborted) {
reject(new RuntimeErrorClass("Command execution was aborted", "exec"));
return;
}
if (signal === "SIGTERM" && options.timeout !== undefined) {
reject(
new RuntimeErrorClass(`Command exceeded timeout of ${options.timeout} seconds`, "exec")
);
return;
}
resolve(code ?? (signal ? -1 : 0));
});

childProcess.on("error", (err) => {
reject(new RuntimeErrorClass(`Failed to execute command: ${err.message}`, "exec", err));
});
});

const duration = exitCode.then(() => performance.now() - startTime);

// Handle abort signal
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", () => childProcess.kill());
}

// Handle timeout
if (options.timeout !== undefined) {
setTimeout(() => childProcess.kill(), options.timeout * 1000);
}

return { stdout, stderr, stdin, exitCode, duration };
}

readFile(filePath: string): ReadableStream<Uint8Array> {
const nodeStream = fs.createReadStream(filePath);

// Handle errors by wrapping in a transform
const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;

return new ReadableStream<Uint8Array>({
async start(controller: ReadableStreamDefaultController<Uint8Array>) {
try {
const reader = webStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (err) {
controller.error(
new RuntimeErrorClass(
`Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
"file_io",
err instanceof Error ? err : undefined
)
);
}
},
});
}

writeFile(filePath: string): WritableStream<Uint8Array> {
let tempPath: string;
let writer: WritableStreamDefaultWriter<Uint8Array>;

return new WritableStream<Uint8Array>({
async start() {
// Create parent directories if they don't exist
const parentDir = path.dirname(filePath);
await fsPromises.mkdir(parentDir, { recursive: true });

// Create temp file for atomic write
tempPath = `${filePath}.tmp.${Date.now()}`;
const nodeStream = fs.createWriteStream(tempPath);
const webStream = Writable.toWeb(nodeStream) as WritableStream<Uint8Array>;
writer = webStream.getWriter();
},
async write(chunk: Uint8Array) {
await writer.write(chunk);
},
async close() {
// Close the writer and rename to final location
await writer.close();
try {
await fsPromises.rename(tempPath, filePath);
} catch (err) {
throw new RuntimeErrorClass(
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
"file_io",
err instanceof Error ? err : undefined
);
}
},
async abort(reason?: unknown) {
// Clean up temp file on abort
await writer.abort();
try {
await fsPromises.unlink(tempPath);
} catch {
// Ignore errors cleaning up temp file
}
throw new RuntimeErrorClass(
`Failed to write file ${filePath}: ${String(reason)}`,
"file_io"
);
},
});
}

async stat(filePath: string): Promise<FileStat> {
try {
const stats = await fsPromises.stat(filePath);
return {
size: stats.size,
modifiedTime: stats.mtime,
isDirectory: stats.isDirectory(),
};
} catch (err) {
throw new RuntimeErrorClass(
`Failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
"file_io",
err instanceof Error ? err : undefined
);
}
}
}
114 changes: 114 additions & 0 deletions src/runtime/Runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Runtime abstraction for executing tools in different environments.
*
* DESIGN PRINCIPLE: Keep this interface minimal and low-level.
* - Prefer streaming primitives over buffered APIs
* - Implement shared helpers (utils/runtime/) that work across all runtimes
* - Avoid duplicating helper logic in each runtime implementation
*
* This interface allows tools to run locally, in Docker containers, over SSH, etc.
*/

/**
* Options for executing a command
*/
export interface ExecOptions {
/** Working directory for command execution */
cwd: string;
/** Environment variables to inject */
env?: Record<string, string>;
/**
* Timeout in seconds (REQUIRED)
*
* Prevents zombie processes by ensuring all spawned processes are eventually killed.
* Even long-running commands should have a reasonable upper bound (e.g., 3600s for 1 hour).
*/
timeout: number;
/** Process niceness level (-20 to 19, lower = higher priority) */
niceness?: number;
/** Abort signal for cancellation */
abortSignal?: AbortSignal;
}

/**
* Streaming result from executing a command
*/
export interface ExecStream {
/** Standard output stream */
stdout: ReadableStream<Uint8Array>;
/** Standard error stream */
stderr: ReadableStream<Uint8Array>;
/** Standard input stream */
stdin: WritableStream<Uint8Array>;
/** Promise that resolves with exit code when process completes */
exitCode: Promise<number>;
/** Promise that resolves with wall clock duration in milliseconds */
duration: Promise<number>;
}

/**
* File statistics
*/
export interface FileStat {
/** File size in bytes */
size: number;
/** Last modified time */
modifiedTime: Date;
/** True if path is a directory (false implies regular file for our purposes) */
isDirectory: boolean;
}

/**
* Runtime interface - minimal, low-level abstraction for tool execution environments.
*
* All methods return streaming primitives for memory efficiency.
* Use helpers in utils/runtime/ for convenience wrappers (e.g., readFileString, execBuffered).
*/
export interface Runtime {
/**
* Execute a bash command with streaming I/O
* @param command The bash script to execute
* @param options Execution options (cwd, env, timeout, etc.)
* @returns Streaming handles for stdin/stdout/stderr and completion promises
* @throws RuntimeError if execution fails in an unrecoverable way
*/
exec(command: string, options: ExecOptions): ExecStream;

/**
* Read file contents as a stream
* @param path Absolute or relative path to file
* @returns Readable stream of file contents
* @throws RuntimeError if file cannot be read
*/
readFile(path: string): ReadableStream<Uint8Array>;

/**
* Write file contents atomically from a stream
* @param path Absolute or relative path to file
* @returns Writable stream for file contents
* @throws RuntimeError if file cannot be written
*/
writeFile(path: string): WritableStream<Uint8Array>;

/**
* Get file statistics
* @param path Absolute or relative path to file/directory
* @returns File statistics
* @throws RuntimeError if path does not exist or cannot be accessed
*/
stat(path: string): Promise<FileStat>;
}

/**
* Error thrown by runtime implementations
*/
export class RuntimeError extends Error {
constructor(
message: string,
public readonly type: "exec" | "file_io" | "network" | "unknown",
public readonly cause?: Error
) {
super(message);
this.name = "RuntimeError";
}
}
Loading
Loading