@oh-my-pi/pi-mom 0.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/src/sandbox.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { logger } from "@oh-my-pi/pi-coding-agent";
2
+
3
+ export type SandboxConfig = { type: "host" } | { type: "docker"; container: string };
4
+
5
+ export function parseSandboxArg(value: string): SandboxConfig {
6
+ if (value === "host") {
7
+ return { type: "host" };
8
+ }
9
+ if (value.startsWith("docker:")) {
10
+ const container = value.slice("docker:".length);
11
+ if (!container) {
12
+ console.error("Error: docker sandbox requires container name (e.g., docker:mom-sandbox)");
13
+ process.exit(1);
14
+ }
15
+ return { type: "docker", container };
16
+ }
17
+ console.error(`Error: Invalid sandbox type '${value}'. Use 'host' or 'docker:<container-name>'`);
18
+ process.exit(1);
19
+ }
20
+
21
+ export async function validateSandbox(config: SandboxConfig): Promise<void> {
22
+ if (config.type === "host") {
23
+ return;
24
+ }
25
+
26
+ // Check if Docker is available
27
+ try {
28
+ await execSimple("docker", ["--version"]);
29
+ } catch {
30
+ console.error("Error: Docker is not installed or not in PATH");
31
+ process.exit(1);
32
+ }
33
+
34
+ // Check if container exists and is running
35
+ try {
36
+ const result = await execSimple("docker", ["inspect", "-f", "{{.State.Running}}", config.container]);
37
+ if (result.trim() !== "true") {
38
+ console.error(`Error: Container '${config.container}' is not running.`);
39
+ console.error(`Start it with: docker start ${config.container}`);
40
+ process.exit(1);
41
+ }
42
+ } catch {
43
+ console.error(`Error: Container '${config.container}' does not exist.`);
44
+ console.error("Create it with: ./docker.sh create <data-dir>");
45
+ process.exit(1);
46
+ }
47
+
48
+ console.log(` Docker container '${config.container}' is running.`);
49
+ }
50
+
51
+ async function execSimple(cmd: string, args: string[]): Promise<string> {
52
+ const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" });
53
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
54
+ const code = await proc.exited;
55
+ if (code === 0) return stdout;
56
+ throw new Error(stderr || `Exit code ${code}`);
57
+ }
58
+
59
+ /**
60
+ * Create an executor that runs commands either on host or in Docker container
61
+ */
62
+ export function createExecutor(config: SandboxConfig): Executor {
63
+ if (config.type === "host") {
64
+ return new HostExecutor();
65
+ }
66
+ return new DockerExecutor(config.container);
67
+ }
68
+
69
+ export interface Executor {
70
+ /**
71
+ * Execute a bash command
72
+ */
73
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
74
+
75
+ /**
76
+ * Get the workspace path prefix for this executor
77
+ * Host: returns the actual path
78
+ * Docker: returns /workspace
79
+ */
80
+ getWorkspacePath(hostPath: string): string;
81
+ }
82
+
83
+ export interface ExecOptions {
84
+ timeout?: number;
85
+ signal?: AbortSignal;
86
+ }
87
+
88
+ export interface ExecResult {
89
+ stdout: string;
90
+ stderr: string;
91
+ code: number;
92
+ }
93
+
94
+ class HostExecutor implements Executor {
95
+ async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
96
+ const shell = process.platform === "win32" ? "cmd" : "sh";
97
+ const shellArgs = process.platform === "win32" ? ["/c"] : ["-c"];
98
+
99
+ // Fix 4: Use object for flag visibility across async boundaries
100
+ const state = { timedOut: false };
101
+ let proc: ReturnType<typeof Bun.spawn> | null = null;
102
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
103
+ const abortControllers: AbortController[] = [];
104
+
105
+ const cleanup = () => {
106
+ if (timeoutHandle) clearTimeout(timeoutHandle);
107
+ if (options?.signal) {
108
+ options.signal.removeEventListener("abort", onAbort);
109
+ }
110
+ // Cancel all stream readers
111
+ for (const controller of abortControllers) {
112
+ controller.abort();
113
+ }
114
+ };
115
+
116
+ const onAbort = () => {
117
+ // Fix 1: Capture process reference atomically
118
+ const p = proc;
119
+ if (p) killProcessTree(p.pid);
120
+ };
121
+
122
+ // Fix 2: Register signal listener before spawning process
123
+ if (options?.signal) {
124
+ if (options.signal.aborted) {
125
+ throw new Error("Command aborted");
126
+ }
127
+ options.signal.addEventListener("abort", onAbort, { once: true });
128
+ }
129
+
130
+ proc = Bun.spawn([shell, ...shellArgs, command], {
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+
135
+ // Start timeout after process is spawned
136
+ if (options?.timeout && options.timeout > 0) {
137
+ timeoutHandle = setTimeout(() => {
138
+ state.timedOut = true;
139
+ // Fix 1: Capture process reference atomically
140
+ const p = proc;
141
+ if (p) killProcessTree(p.pid);
142
+ }, options.timeout * 1000);
143
+ }
144
+
145
+ const MAX_BYTES = 10 * 1024 * 1024;
146
+
147
+ // Fix 3: Make stream readers cancellable
148
+ const readStream = async (stream: ReadableStream<Uint8Array>, abortSignal: AbortSignal): Promise<string> => {
149
+ const reader = stream.getReader();
150
+ const decoder = new TextDecoder();
151
+ let result = "";
152
+ try {
153
+ while (true) {
154
+ if (abortSignal.aborted) break;
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+ result += decoder.decode(value, { stream: true });
158
+ if (result.length > MAX_BYTES) {
159
+ result = result.slice(0, MAX_BYTES);
160
+ break;
161
+ }
162
+ }
163
+ } finally {
164
+ reader.releaseLock();
165
+ }
166
+ return result;
167
+ };
168
+
169
+ const stdoutController = new AbortController();
170
+ const stderrController = new AbortController();
171
+ abortControllers.push(stdoutController, stderrController);
172
+
173
+ const [stdout, stderr] = await Promise.all([
174
+ readStream(proc.stdout as ReadableStream<Uint8Array>, stdoutController.signal),
175
+ readStream(proc.stderr as ReadableStream<Uint8Array>, stderrController.signal),
176
+ ]);
177
+
178
+ const code = await proc.exited;
179
+
180
+ cleanup();
181
+
182
+ if (options?.signal?.aborted) {
183
+ throw new Error(`${stdout}\n${stderr}\nCommand aborted`.trim());
184
+ }
185
+
186
+ if (state.timedOut) {
187
+ throw new Error(`${stdout}\n${stderr}\nCommand timed out after ${options?.timeout} seconds`.trim());
188
+ }
189
+
190
+ return { stdout, stderr, code };
191
+ }
192
+
193
+ getWorkspacePath(hostPath: string): string {
194
+ return hostPath;
195
+ }
196
+ }
197
+
198
+ class DockerExecutor implements Executor {
199
+ constructor(private container: string) {}
200
+
201
+ async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
202
+ // Wrap command for docker exec
203
+ const dockerCmd = `docker exec ${this.container} sh -c ${shellEscape(command)}`;
204
+ const hostExecutor = new HostExecutor();
205
+ return hostExecutor.exec(dockerCmd, options);
206
+ }
207
+
208
+ getWorkspacePath(_hostPath: string): string {
209
+ // Docker container sees /workspace
210
+ return "/workspace";
211
+ }
212
+ }
213
+
214
+ function killProcessTree(pid: number): void {
215
+ if (process.platform === "win32") {
216
+ try {
217
+ Bun.spawn(["taskkill", "/F", "/T", "/PID", String(pid)], { stdout: "ignore", stderr: "ignore" });
218
+ } catch (err) {
219
+ logger.debug("Process kill failed", { pid, error: String(err) });
220
+ }
221
+ } else {
222
+ try {
223
+ process.kill(-pid, "SIGKILL");
224
+ } catch (err) {
225
+ logger.debug("Process kill failed", { pid, error: String(err) });
226
+ try {
227
+ process.kill(pid, "SIGKILL");
228
+ } catch (err) {
229
+ logger.debug("Process kill failed", { pid, error: String(err) });
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ function shellEscape(s: string): string {
236
+ // Escape for passing to sh -c
237
+ return `'${s.replace(/'/g, "'\\''")}'`;
238
+ }