@wavexzore/sandbox 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.
Files changed (149) hide show
  1. package/Dockerfile +14 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +3 -0
  4. package/README.md +153 -0
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.js +9 -0
  7. package/dist/sandbox/cli/install.d.ts +5 -0
  8. package/dist/sandbox/cli/install.js +335 -0
  9. package/dist/sandbox/cli/local-store.d.ts +87 -0
  10. package/dist/sandbox/cli/local-store.js +604 -0
  11. package/dist/sandbox/cli/opencode-config.d.ts +25 -0
  12. package/dist/sandbox/cli/opencode-config.js +240 -0
  13. package/dist/sandbox/cli/path.d.ts +64 -0
  14. package/dist/sandbox/cli/path.js +127 -0
  15. package/dist/sandbox/cli/types.d.ts +145 -0
  16. package/dist/sandbox/cli/types.js +6 -0
  17. package/dist/sandbox/cli/wavexzore-sandbox.d.ts +65 -0
  18. package/dist/sandbox/cli/wavexzore-sandbox.js +577 -0
  19. package/dist/sandbox/core/cli-helper.d.ts +19 -0
  20. package/dist/sandbox/core/cli-helper.js +64 -0
  21. package/dist/sandbox/core/docker-archive-utils.d.ts +3 -0
  22. package/dist/sandbox/core/docker-archive-utils.js +50 -0
  23. package/dist/sandbox/core/docker-sandbox.d.ts +51 -0
  24. package/dist/sandbox/core/docker-sandbox.js +675 -0
  25. package/dist/sandbox/core/edit/filediff.d.ts +16 -0
  26. package/dist/sandbox/core/edit/filediff.js +21 -0
  27. package/dist/sandbox/core/edit/index.d.ts +5 -0
  28. package/dist/sandbox/core/edit/index.js +5 -0
  29. package/dist/sandbox/core/edit/line-endings.d.ts +4 -0
  30. package/dist/sandbox/core/edit/line-endings.js +10 -0
  31. package/dist/sandbox/core/edit/lock.d.ts +1 -0
  32. package/dist/sandbox/core/edit/lock.js +18 -0
  33. package/dist/sandbox/core/edit/replace.d.ts +10 -0
  34. package/dist/sandbox/core/edit/replace.js +14 -0
  35. package/dist/sandbox/core/edit/replacers.d.ts +15 -0
  36. package/dist/sandbox/core/edit/replacers.js +241 -0
  37. package/dist/sandbox/core/logger.d.ts +15 -0
  38. package/dist/sandbox/core/logger.js +59 -0
  39. package/dist/sandbox/core/lsp/client.d.ts +63 -0
  40. package/dist/sandbox/core/lsp/client.js +533 -0
  41. package/dist/sandbox/core/lsp/config.d.ts +13 -0
  42. package/dist/sandbox/core/lsp/config.js +36 -0
  43. package/dist/sandbox/core/lsp/diagnostics.d.ts +12 -0
  44. package/dist/sandbox/core/lsp/diagnostics.js +65 -0
  45. package/dist/sandbox/core/lsp/index.d.ts +4 -0
  46. package/dist/sandbox/core/lsp/index.js +4 -0
  47. package/dist/sandbox/core/lsp/language.d.ts +24 -0
  48. package/dist/sandbox/core/lsp/language.js +249 -0
  49. package/dist/sandbox/core/lsp/manager.d.ts +77 -0
  50. package/dist/sandbox/core/lsp/manager.js +237 -0
  51. package/dist/sandbox/core/lsp/tooling.d.ts +14 -0
  52. package/dist/sandbox/core/lsp/tooling.js +78 -0
  53. package/dist/sandbox/core/patch-parser.d.ts +23 -0
  54. package/dist/sandbox/core/patch-parser.js +248 -0
  55. package/dist/sandbox/core/path-map.d.ts +9 -0
  56. package/dist/sandbox/core/path-map.js +73 -0
  57. package/dist/sandbox/core/project-data-storage.d.ts +42 -0
  58. package/dist/sandbox/core/project-data-storage.js +167 -0
  59. package/dist/sandbox/core/read/binary.d.ts +4 -0
  60. package/dist/sandbox/core/read/binary.js +80 -0
  61. package/dist/sandbox/core/read/format.d.ts +38 -0
  62. package/dist/sandbox/core/read/format.js +85 -0
  63. package/dist/sandbox/core/read/index.d.ts +3 -0
  64. package/dist/sandbox/core/read/index.js +3 -0
  65. package/dist/sandbox/core/read/permissions.d.ts +7 -0
  66. package/dist/sandbox/core/read/permissions.js +13 -0
  67. package/dist/sandbox/core/session-manager.d.ts +29 -0
  68. package/dist/sandbox/core/session-manager.js +338 -0
  69. package/dist/sandbox/core/shell/config.d.ts +7 -0
  70. package/dist/sandbox/core/shell/config.js +82 -0
  71. package/dist/sandbox/core/shell/output.d.ts +35 -0
  72. package/dist/sandbox/core/shell/output.js +80 -0
  73. package/dist/sandbox/core/shell/parser.d.ts +7 -0
  74. package/dist/sandbox/core/shell/parser.js +122 -0
  75. package/dist/sandbox/core/shell/permissions.d.ts +13 -0
  76. package/dist/sandbox/core/shell/permissions.js +33 -0
  77. package/dist/sandbox/core/shell/workdir.d.ts +4 -0
  78. package/dist/sandbox/core/shell/workdir.js +19 -0
  79. package/dist/sandbox/core/stream-utils.d.ts +23 -0
  80. package/dist/sandbox/core/stream-utils.js +97 -0
  81. package/dist/sandbox/core/toast.d.ts +47 -0
  82. package/dist/sandbox/core/toast.js +73 -0
  83. package/dist/sandbox/core/types.d.ts +159 -0
  84. package/dist/sandbox/core/types.js +11 -0
  85. package/dist/sandbox/core/write/bom.d.ts +8 -0
  86. package/dist/sandbox/core/write/bom.js +15 -0
  87. package/dist/sandbox/core/write/config.d.ts +14 -0
  88. package/dist/sandbox/core/write/config.js +188 -0
  89. package/dist/sandbox/core/write/diagnostics.d.ts +19 -0
  90. package/dist/sandbox/core/write/diagnostics.js +120 -0
  91. package/dist/sandbox/core/write/diff.d.ts +7 -0
  92. package/dist/sandbox/core/write/diff.js +21 -0
  93. package/dist/sandbox/core/write/formatter.d.ts +16 -0
  94. package/dist/sandbox/core/write/formatter.js +51 -0
  95. package/dist/sandbox/core/write/index.d.ts +6 -0
  96. package/dist/sandbox/core/write/index.js +5 -0
  97. package/dist/sandbox/core/write/permissions.d.ts +13 -0
  98. package/dist/sandbox/core/write/permissions.js +19 -0
  99. package/dist/sandbox/core/write/pipeline.d.ts +48 -0
  100. package/dist/sandbox/core/write/pipeline.js +229 -0
  101. package/dist/sandbox/core/write/read-tracker.d.ts +13 -0
  102. package/dist/sandbox/core/write/read-tracker.js +30 -0
  103. package/dist/sandbox/git/host-git-manager.d.ts +40 -0
  104. package/dist/sandbox/git/host-git-manager.js +278 -0
  105. package/dist/sandbox/git/index.d.ts +5 -0
  106. package/dist/sandbox/git/index.js +5 -0
  107. package/dist/sandbox/git/sandbox-git-manager.d.ts +14 -0
  108. package/dist/sandbox/git/sandbox-git-manager.js +54 -0
  109. package/dist/sandbox/git/session-git-manager.d.ts +18 -0
  110. package/dist/sandbox/git/session-git-manager.js +85 -0
  111. package/dist/sandbox/index.d.ts +205 -0
  112. package/dist/sandbox/index.js +70 -0
  113. package/dist/sandbox/plugins/custom-tools.d.ts +203 -0
  114. package/dist/sandbox/plugins/custom-tools.js +15 -0
  115. package/dist/sandbox/plugins/session-events.d.ts +10 -0
  116. package/dist/sandbox/plugins/session-events.js +56 -0
  117. package/dist/sandbox/plugins/system-transform.d.ts +10 -0
  118. package/dist/sandbox/plugins/system-transform.js +23 -0
  119. package/dist/sandbox/tools/bash-output.d.ts +17 -0
  120. package/dist/sandbox/tools/bash-output.js +35 -0
  121. package/dist/sandbox/tools/bash-status.d.ts +13 -0
  122. package/dist/sandbox/tools/bash-status.js +29 -0
  123. package/dist/sandbox/tools/bash-stop.d.ts +13 -0
  124. package/dist/sandbox/tools/bash-stop.js +28 -0
  125. package/dist/sandbox/tools/bash.d.ts +26 -0
  126. package/dist/sandbox/tools/bash.js +120 -0
  127. package/dist/sandbox/tools/edit.d.ts +20 -0
  128. package/dist/sandbox/tools/edit.js +87 -0
  129. package/dist/sandbox/tools/get-preview-url.d.ts +17 -0
  130. package/dist/sandbox/tools/get-preview-url.js +16 -0
  131. package/dist/sandbox/tools/glob.d.ts +17 -0
  132. package/dist/sandbox/tools/glob.js +23 -0
  133. package/dist/sandbox/tools/grep.d.ts +17 -0
  134. package/dist/sandbox/tools/grep.js +23 -0
  135. package/dist/sandbox/tools/ls.d.ts +17 -0
  136. package/dist/sandbox/tools/ls.js +21 -0
  137. package/dist/sandbox/tools/lsp.d.ts +41 -0
  138. package/dist/sandbox/tools/lsp.js +198 -0
  139. package/dist/sandbox/tools/multiedit.d.ts +24 -0
  140. package/dist/sandbox/tools/multiedit.js +83 -0
  141. package/dist/sandbox/tools/patch.d.ts +14 -0
  142. package/dist/sandbox/tools/patch.js +260 -0
  143. package/dist/sandbox/tools/read.d.ts +22 -0
  144. package/dist/sandbox/tools/read.js +105 -0
  145. package/dist/sandbox/tools/write.d.ts +16 -0
  146. package/dist/sandbox/tools/write.js +27 -0
  147. package/dist/sandbox/tools.d.ts +200 -0
  148. package/dist/sandbox/tools.js +43 -0
  149. package/package.json +55 -0
@@ -0,0 +1,675 @@
1
+ import { execSync } from 'child_process';
2
+ import { randomUUID } from 'crypto';
3
+ import { execInContainer, parseGrepOutput } from './stream-utils.js';
4
+ import { getFileFromContainer, putFileToContainer } from './docker-archive-utils.js';
5
+ import { DEFAULT_SHELL_MAX_OUTPUT_BYTES, DEFAULT_SHELL_MAX_OUTPUT_LINES, formatShellOutput, writeHostShellOutput, } from './shell/output.js';
6
+ export class DockerSandbox {
7
+ docker;
8
+ container;
9
+ _state = 'created';
10
+ _workDir;
11
+ _id;
12
+ backgroundCommands = new Map();
13
+ constructor(docker, container, workDir) {
14
+ this.docker = docker;
15
+ this.container = container;
16
+ this._workDir = workDir;
17
+ this._id = container.id;
18
+ }
19
+ get id() {
20
+ return this._id.slice(0, 12);
21
+ }
22
+ get state() {
23
+ return this._state;
24
+ }
25
+ async refreshData() {
26
+ const info = await this.container.inspect();
27
+ this._state = normalizeDockerState(info.State.Status);
28
+ }
29
+ async start() {
30
+ const info = await this.container.inspect();
31
+ if (!info.State.Running) {
32
+ await this.container.start();
33
+ }
34
+ this._state = 'started';
35
+ }
36
+ async delete() {
37
+ try {
38
+ await this.container.stop();
39
+ }
40
+ catch (error) {
41
+ void error;
42
+ }
43
+ await this.container.remove({ force: true });
44
+ }
45
+ deleteSync() {
46
+ try {
47
+ execSync(`docker rm -f ${this._id}`, { stdio: 'pipe', timeout: 10000 });
48
+ }
49
+ catch (error) {
50
+ void error;
51
+ }
52
+ }
53
+ get process() {
54
+ return {
55
+ executeCommand: async (command, cwd) => {
56
+ assertNonEmptyCommand(command);
57
+ if (typeof cwd === 'object' && cwd !== null) {
58
+ return this.executeManagedCommand(command, cwd);
59
+ }
60
+ const directCwd = typeof cwd === 'string' ? cwd : undefined;
61
+ const { exitCode, stdout, stderr } = await execInContainer(this.container, ['/bin/sh', '-c', command], directCwd || this._workDir);
62
+ return {
63
+ exitCode,
64
+ result: stdout + stderr,
65
+ stdout,
66
+ stderr,
67
+ durationMs: 0,
68
+ timedOut: false,
69
+ aborted: false,
70
+ truncated: false,
71
+ stdoutBytes: Buffer.byteLength(stdout, 'utf8'),
72
+ stderrBytes: Buffer.byteLength(stderr, 'utf8'),
73
+ };
74
+ },
75
+ startBackgroundCommand: async (command, opts) => {
76
+ return this.startManagedShellCommand(command, opts);
77
+ },
78
+ getBackgroundCommand: async (cmdId) => {
79
+ return this.getManagedCommandStatus(cmdId);
80
+ },
81
+ readBackgroundCommand: async (cmdId, opts) => {
82
+ return this.readManagedCommandOutput(cmdId, opts);
83
+ },
84
+ stopBackgroundCommand: async (cmdId) => {
85
+ return this.stopManagedCommand(cmdId);
86
+ },
87
+ createSession: async (sessionId) => {
88
+ await execInContainer(this.container, ['/bin/sh', '-c', `tmux new-session -d -s '${escapeShell(sessionId)}' 2>/dev/null || true`], this._workDir);
89
+ },
90
+ getSession: async (sessionId) => {
91
+ const { exitCode } = await execInContainer(this.container, [
92
+ '/bin/sh',
93
+ '-c',
94
+ `tmux has-session -t '${escapeShell(sessionId)}'`,
95
+ ]);
96
+ if (exitCode !== 0)
97
+ throw new Error(`Session ${sessionId} not found`);
98
+ },
99
+ executeSessionCommand: async (sessionId, opts) => {
100
+ const b64Cmd = Buffer.from(opts.command).toString('base64');
101
+ const shellCmd = `tmux send-keys -t '${escapeShell(sessionId)}' "$(echo '${b64Cmd}' | base64 -d)" Enter`;
102
+ await execInContainer(this.container, ['/bin/sh', '-c', shellCmd], opts.cwd || this._workDir);
103
+ return { cmdId: `tmux-${sessionId}-${Date.now()}` };
104
+ },
105
+ };
106
+ }
107
+ get fs() {
108
+ return {
109
+ downloadFile: async (filePath) => {
110
+ return getFileFromContainer(this.container, filePath);
111
+ },
112
+ uploadFile: async (content, filePath) => {
113
+ return putFileToContainer(this.container, filePath, content);
114
+ },
115
+ createFolder: async (path, mode) => {
116
+ await execInContainer(this.container, ['/bin/sh', '-c', `mkdir -p -m ${mode || '755'} "${path}"`], this._workDir);
117
+ },
118
+ listFiles: async (dirPath) => {
119
+ const quoted = `'${escapeShell(dirPath)}'`;
120
+ const listCommand = [
121
+ `for entry in ${quoted}/* ${quoted}/.[!.]* ${quoted}/..?*; do`,
122
+ `[ -e "$entry" ] || continue;`,
123
+ 'name="${entry##*/}";',
124
+ `[ "$name" = "." ] || [ "$name" = ".." ] && continue;`,
125
+ `if [ -d "$entry" ]; then type=d; size=0; else type=f; size=$(wc -c < "$entry" 2>/dev/null || echo 0); fi;`,
126
+ `printf '%s\\t%s\\t%s\\n' "$type" "$size" "$name";`,
127
+ `done`,
128
+ ].join(' ');
129
+ const { exitCode, stdout } = await execInContainer(this.container, ['/bin/sh', '-c', listCommand], this._workDir);
130
+ if (exitCode !== 0)
131
+ return [];
132
+ return stdout
133
+ .trim()
134
+ .split('\n')
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const [type, size, ...nameParts] = line.split('\t');
138
+ const parsedSize = Number.parseInt(size ?? '0', 10);
139
+ return {
140
+ name: nameParts.join('\t'),
141
+ isDirectory: type === 'd',
142
+ size: Number.isFinite(parsedSize) ? parsedSize : 0,
143
+ };
144
+ });
145
+ },
146
+ searchFiles: async (path, pattern) => {
147
+ const { stdout } = await execInContainer(this.container, ['/bin/sh', '-c', `find "${path}" -path "${pattern}" -type f 2>/dev/null`], this._workDir);
148
+ return { files: stdout.trim().split('\n').filter(Boolean) };
149
+ },
150
+ findFiles: async (path, pattern) => {
151
+ const { stdout } = await execInContainer(this.container, ['/bin/sh', '-c', `grep -rn "${pattern}" "${path}" 2>/dev/null | head -100`], this._workDir);
152
+ return parseGrepOutput(stdout);
153
+ },
154
+ };
155
+ }
156
+ async getWorkDir() {
157
+ return this._workDir;
158
+ }
159
+ async getPreviewLink(port) {
160
+ const info = await this.container.inspect();
161
+ const bindings = info.NetworkSettings?.Ports?.[`${port}/tcp`];
162
+ if (bindings && bindings.length > 0) {
163
+ const hostPort = bindings[0].HostPort;
164
+ return { url: `http://localhost:${hostPort}` };
165
+ }
166
+ return { url: `http://localhost:${port}` };
167
+ }
168
+ async executeManagedCommand(command, opts) {
169
+ assertNonEmptyCommand(command);
170
+ const startedAt = Date.now();
171
+ const timeoutMs = opts.timeoutMs;
172
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_SHELL_MAX_OUTPUT_BYTES;
173
+ const maxOutputLines = opts.maxOutputLines ?? DEFAULT_SHELL_MAX_OUTPUT_LINES;
174
+ let lastUpdateAt = 0;
175
+ const commandInfo = await this.startManagedShellCommand(command, {
176
+ cwd: opts.cwd || this._workDir,
177
+ sessionId: opts.sessionId,
178
+ });
179
+ while (true) {
180
+ if (opts.signal?.aborted) {
181
+ await this.stopManagedCommand(commandInfo.cmdId);
182
+ const output = await this.readManagedCommandOutput(commandInfo.cmdId, { maxOutputBytes, maxOutputLines });
183
+ const outputPath = output.truncated
184
+ ? await this.persistFullOutput(opts.sessionId, commandInfo.cmdId, output)
185
+ : undefined;
186
+ const durationMs = Date.now() - startedAt;
187
+ return {
188
+ exitCode: null,
189
+ result: formatShellOutput({
190
+ ...output,
191
+ exitCode: null,
192
+ durationMs,
193
+ aborted: true,
194
+ outputPath,
195
+ }),
196
+ stdout: output.stdout,
197
+ stderr: output.stderr,
198
+ durationMs,
199
+ aborted: true,
200
+ timedOut: false,
201
+ truncated: output.truncated,
202
+ outputPath,
203
+ stdoutBytes: output.stdoutBytes,
204
+ stderrBytes: output.stderrBytes,
205
+ stdoutLines: output.stdoutLines,
206
+ stderrLines: output.stderrLines,
207
+ cmdId: commandInfo.cmdId,
208
+ };
209
+ }
210
+ if (timeoutMs && Date.now() - startedAt >= timeoutMs) {
211
+ await this.stopManagedCommand(commandInfo.cmdId);
212
+ const output = await this.readManagedCommandOutput(commandInfo.cmdId, { maxOutputBytes, maxOutputLines });
213
+ const outputPath = output.truncated
214
+ ? await this.persistFullOutput(opts.sessionId, commandInfo.cmdId, output)
215
+ : undefined;
216
+ const durationMs = Date.now() - startedAt;
217
+ return {
218
+ exitCode: null,
219
+ result: formatShellOutput({
220
+ ...output,
221
+ exitCode: null,
222
+ durationMs,
223
+ timedOut: true,
224
+ outputPath,
225
+ }),
226
+ stdout: output.stdout,
227
+ stderr: output.stderr,
228
+ durationMs,
229
+ aborted: false,
230
+ timedOut: true,
231
+ truncated: output.truncated,
232
+ outputPath,
233
+ stdoutBytes: output.stdoutBytes,
234
+ stderrBytes: output.stderrBytes,
235
+ stdoutLines: output.stdoutLines,
236
+ stderrLines: output.stderrLines,
237
+ cmdId: commandInfo.cmdId,
238
+ };
239
+ }
240
+ const status = await this.getManagedCommandStatus(commandInfo.cmdId);
241
+ if (status.status === 'exited' || status.status === 'failed' || status.status === 'stopped') {
242
+ const output = await this.readManagedCommandOutput(commandInfo.cmdId, { maxOutputBytes, maxOutputLines });
243
+ const outputPath = output.truncated
244
+ ? await this.persistFullOutput(opts.sessionId, commandInfo.cmdId, output)
245
+ : undefined;
246
+ const durationMs = Date.now() - startedAt;
247
+ return {
248
+ exitCode: status.exitCode,
249
+ result: formatShellOutput({
250
+ ...output,
251
+ exitCode: status.exitCode,
252
+ durationMs,
253
+ truncated: output.truncated,
254
+ outputPath,
255
+ }),
256
+ stdout: output.stdout,
257
+ stderr: output.stderr,
258
+ durationMs,
259
+ aborted: false,
260
+ timedOut: false,
261
+ truncated: output.truncated,
262
+ outputPath,
263
+ stdoutBytes: output.stdoutBytes,
264
+ stderrBytes: output.stderrBytes,
265
+ stdoutLines: output.stdoutLines,
266
+ stderrLines: output.stderrLines,
267
+ cmdId: commandInfo.cmdId,
268
+ };
269
+ }
270
+ if (opts.onUpdate && Date.now() - lastUpdateAt >= 1_000) {
271
+ const output = await this.readManagedCommandOutput(commandInfo.cmdId, { maxOutputBytes, maxOutputLines });
272
+ opts.onUpdate({
273
+ ...output,
274
+ durationMs: Date.now() - startedAt,
275
+ });
276
+ lastUpdateAt = Date.now();
277
+ }
278
+ await sleep(150);
279
+ }
280
+ }
281
+ async startManagedShellCommand(command, opts) {
282
+ assertNonEmptyCommand(command);
283
+ const sessionId = safeId(opts?.sessionId || 'session');
284
+ const cmdId = `shell-${Date.now()}-${randomUUID().slice(0, 8)}`;
285
+ const runDir = `/tmp/sandbox-shell/${sessionId}/${safeId(cmdId)}`;
286
+ const cwd = opts?.cwd || this._workDir;
287
+ const startedAt = Date.now();
288
+ await execInContainer(this.container, ['/bin/sh', '-c', `mkdir -p ${shellQuote(runDir)}`], this._workDir, {
289
+ tty: false,
290
+ });
291
+ await this.writeContainerTextFile(`${runDir}/command.sh`, command);
292
+ await this.writeContainerTextFile(`${runDir}/runner.sh`, this.runnerScript(runDir, cwd, cmdId));
293
+ await execInContainer(this.container, [
294
+ '/bin/sh',
295
+ '-c',
296
+ [
297
+ `chmod 700 ${shellQuote(`${runDir}/runner.sh`)} ${shellQuote(`${runDir}/command.sh`)}`,
298
+ 'if command -v setsid >/dev/null 2>&1; then',
299
+ ` setsid /bin/sh ${shellQuote(`${runDir}/runner.sh`)} >/dev/null 2>&1 < /dev/null &`,
300
+ 'else',
301
+ ` nohup /bin/sh ${shellQuote(`${runDir}/runner.sh`)} >/dev/null 2>&1 < /dev/null &`,
302
+ 'fi',
303
+ `echo $! > ${shellQuote(`${runDir}/pid`)}`,
304
+ ].join('\n'),
305
+ ], this._workDir, { tty: false });
306
+ this.backgroundCommands.set(cmdId, { runDir, startedAt, cwd });
307
+ return { cmdId, runDir, startedAt };
308
+ }
309
+ runnerScript(runDir, cwd, cmdId) {
310
+ return `#!/bin/sh
311
+ set +e
312
+ RUN_DIR=${shellQuote(runDir)}
313
+ STDOUT="$RUN_DIR/stdout"
314
+ STDERR="$RUN_DIR/stderr"
315
+ EXIT="$RUN_DIR/exit"
316
+ CHILD="$RUN_DIR/child"
317
+ : > "$STDOUT"
318
+ : > "$STDERR"
319
+ date +%s > "$RUN_DIR/started" 2>/dev/null || true
320
+ cd ${shellQuote(cwd)}
321
+ if [ "$?" -ne 0 ]; then
322
+ echo 97 > "$EXIT"
323
+ date +%s > "$RUN_DIR/ended" 2>/dev/null || true
324
+ exit 97
325
+ fi
326
+ ${managedCommandEnv(cmdId, runDir)} /bin/sh "$RUN_DIR/command.sh" > "$STDOUT" 2> "$STDERR" &
327
+ child=$!
328
+ echo "$child" > "$CHILD"
329
+ term() {
330
+ trap '' TERM INT
331
+ kill -TERM -- "-$$" 2>/dev/null || kill -TERM "-$$" 2>/dev/null || true
332
+ kill -TERM "$child" 2>/dev/null || true
333
+ sleep 1
334
+ kill -KILL "$child" 2>/dev/null || true
335
+ echo 143 > "$EXIT"
336
+ date +%s > "$RUN_DIR/ended" 2>/dev/null || true
337
+ exit 143
338
+ }
339
+ trap term TERM INT
340
+ wait "$child"
341
+ code=$?
342
+ echo "$code" > "$EXIT"
343
+ date +%s > "$RUN_DIR/ended" 2>/dev/null || true
344
+ exit "$code"
345
+ `;
346
+ }
347
+ async writeContainerTextFile(filePath, content) {
348
+ let delimiter = `SANDBOX_${randomUUID().replace(/-/g, '')}`;
349
+ while (content.includes(delimiter)) {
350
+ delimiter = `SANDBOX_${randomUUID().replace(/-/g, '')}`;
351
+ }
352
+ const script = `cat > ${shellQuote(filePath)} <<'${delimiter}'
353
+ ${content}
354
+ ${delimiter}
355
+ `;
356
+ const { exitCode, stderr } = await execInContainer(this.container, ['/bin/sh', '-c', script], this._workDir, {
357
+ tty: false,
358
+ });
359
+ if (exitCode !== 0) {
360
+ throw new Error(`Failed to write container command file ${filePath}: ${stderr}`);
361
+ }
362
+ }
363
+ async getManagedCommandStatus(cmdId) {
364
+ const record = this.backgroundCommands.get(cmdId);
365
+ if (!record)
366
+ return { cmdId, status: 'unknown', exitCode: null };
367
+ const runDir = record.runDir;
368
+ const script = `
369
+ if [ -f ${shellQuote(`${runDir}/stopped`)} ]; then
370
+ printf 'status=stopped\\n'
371
+ printf 'exit=%s\\n' "$(cat ${shellQuote(`${runDir}/exit`)} 2>/dev/null || echo 143)"
372
+ elif [ -f ${shellQuote(`${runDir}/exit`)} ]; then
373
+ printf 'status=exited\\n'
374
+ printf 'exit=%s\\n' "$(cat ${shellQuote(`${runDir}/exit`)})"
375
+ elif [ -f ${shellQuote(`${runDir}/pid`)} ] && kill -0 "$(cat ${shellQuote(`${runDir}/pid`)})" 2>/dev/null; then
376
+ printf 'status=running\\n'
377
+ printf 'pid=%s\\n' "$(cat ${shellQuote(`${runDir}/pid`)})"
378
+ else
379
+ printf 'status=failed\\n'
380
+ fi`;
381
+ const { stdout } = await execInContainer(this.container, ['/bin/sh', '-c', script], this._workDir, { tty: false });
382
+ const parsed = parseKeyValues(stdout);
383
+ return {
384
+ cmdId,
385
+ status: parseStatus(parsed.status),
386
+ exitCode: parseNullableInt(parsed.exit),
387
+ pid: parseNullableInt(parsed.pid) ?? undefined,
388
+ runDir,
389
+ };
390
+ }
391
+ async readManagedCommandOutput(cmdId, opts) {
392
+ const record = this.backgroundCommands.get(cmdId);
393
+ if (!record) {
394
+ return {
395
+ cmdId,
396
+ output: `Unknown background command: ${cmdId}`,
397
+ stdout: '',
398
+ stderr: `Unknown background command: ${cmdId}`,
399
+ stdoutBytes: 0,
400
+ stderrBytes: 0,
401
+ truncated: false,
402
+ };
403
+ }
404
+ const maxOutputBytes = opts?.maxOutputBytes ?? DEFAULT_SHELL_MAX_OUTPUT_BYTES;
405
+ const maxOutputLines = opts?.maxOutputLines ?? DEFAULT_SHELL_MAX_OUTPUT_LINES;
406
+ const perStreamLimit = Math.max(1024, Math.floor(maxOutputBytes / 2));
407
+ const perStreamLines = Math.max(1, Math.floor(maxOutputLines / 2));
408
+ const stdoutPath = `${record.runDir}/stdout`;
409
+ const stderrPath = `${record.runDir}/stderr`;
410
+ const [stdoutBytes, stderrBytes, stdoutLines, stderrLines, stdout, stderr] = await Promise.all([
411
+ this.containerFileSize(stdoutPath),
412
+ this.containerFileSize(stderrPath),
413
+ this.containerFileLineCount(stdoutPath),
414
+ this.containerFileLineCount(stderrPath),
415
+ this.readContainerTextTail(stdoutPath, perStreamLimit, perStreamLines),
416
+ this.readContainerTextTail(stderrPath, perStreamLimit, perStreamLines),
417
+ ]);
418
+ const truncated = stdoutBytes > perStreamLimit ||
419
+ stderrBytes > perStreamLimit ||
420
+ stdoutLines > perStreamLines ||
421
+ stderrLines > perStreamLines;
422
+ return {
423
+ cmdId,
424
+ output: [stdout, stderr].filter(Boolean).join('\n'),
425
+ stdout,
426
+ stderr,
427
+ stdoutBytes,
428
+ stderrBytes,
429
+ stdoutLines,
430
+ stderrLines,
431
+ truncated,
432
+ runDir: record.runDir,
433
+ };
434
+ }
435
+ async stopManagedCommand(cmdId) {
436
+ const record = this.backgroundCommands.get(cmdId);
437
+ if (!record)
438
+ return { cmdId, status: 'unknown', exitCode: null };
439
+ const runDir = record.runDir;
440
+ const script = managedCleanupScript(runDir, cmdId);
441
+ await execInContainer(this.container, ['/bin/sh', '-c', script], this._workDir, { tty: false });
442
+ return this.getManagedCommandStatus(cmdId);
443
+ }
444
+ async containerFileSize(filePath) {
445
+ const { stdout } = await execInContainer(this.container, ['/bin/sh', '-c', `if [ -f ${shellQuote(filePath)} ]; then wc -c < ${shellQuote(filePath)}; else echo 0; fi`], this._workDir, { tty: false });
446
+ const parsed = Number.parseInt(stdout.trim(), 10);
447
+ return Number.isFinite(parsed) ? parsed : 0;
448
+ }
449
+ async containerFileLineCount(filePath) {
450
+ const { stdout } = await execInContainer(this.container, ['/bin/sh', '-c', `if [ -f ${shellQuote(filePath)} ]; then wc -l < ${shellQuote(filePath)}; else echo 0; fi`], this._workDir, { tty: false });
451
+ const parsed = Number.parseInt(stdout.trim(), 10);
452
+ return Number.isFinite(parsed) ? parsed : 0;
453
+ }
454
+ async readContainerTextTail(filePath, maxBytes, maxLines) {
455
+ const script = `if [ -f ${shellQuote(filePath)} ]; then tail -n ${Math.max(1, maxLines)} ${shellQuote(filePath)} | tail -c ${Math.max(1, maxBytes)}; fi`;
456
+ const { stdout, stderr } = await execInContainer(this.container, ['/bin/sh', '-c', script], this._workDir, {
457
+ tty: false,
458
+ });
459
+ return stdout + stderr;
460
+ }
461
+ async persistFullOutput(sessionId, cmdId, output) {
462
+ const record = this.backgroundCommands.get(cmdId);
463
+ if (!record)
464
+ return undefined;
465
+ try {
466
+ const [stdout, stderr] = await Promise.all([
467
+ getFileFromContainer(this.container, `${record.runDir}/stdout`).catch(() => Buffer.from(output.stdout)),
468
+ getFileFromContainer(this.container, `${record.runDir}/stderr`).catch(() => Buffer.from(output.stderr)),
469
+ ]);
470
+ return writeHostShellOutput({
471
+ sessionId: sessionId || 'session',
472
+ cmdId,
473
+ stdout: stdout.toString('utf8'),
474
+ stderr: stderr.toString('utf8'),
475
+ });
476
+ }
477
+ catch {
478
+ return undefined;
479
+ }
480
+ }
481
+ }
482
+ function normalizeDockerState(dockerStatus) {
483
+ switch (dockerStatus) {
484
+ case 'running':
485
+ return 'started';
486
+ case 'exited':
487
+ case 'dead':
488
+ case 'removing':
489
+ return 'stopped';
490
+ case 'paused':
491
+ return 'paused';
492
+ case 'created':
493
+ return 'created';
494
+ default:
495
+ return dockerStatus;
496
+ }
497
+ }
498
+ function escapeShell(str) {
499
+ return str.replace(/'/g, "'\\''");
500
+ }
501
+ function shellQuote(str) {
502
+ return `'${str.replace(/'/g, "'\\''")}'`;
503
+ }
504
+ function assertNonEmptyCommand(command) {
505
+ if (command.trim().length === 0) {
506
+ throw new Error('Command must not be empty');
507
+ }
508
+ }
509
+ function managedCommandEnv(cmdId, runDir) {
510
+ return [
511
+ `OPENCODE_SANDBOX_CMD_ID=${shellQuote(cmdId)}`,
512
+ `OPENCODE_SANDBOX_RUN_DIR=${shellQuote(runDir)}`,
513
+ ].join(' ');
514
+ }
515
+ function managedCleanupScript(runDir, cmdId) {
516
+ return `
517
+ RUN_DIR=${shellQuote(runDir)}
518
+ CMD_MARKER=${shellQuote(`OPENCODE_SANDBOX_CMD_ID=${cmdId}`)}
519
+ pid="$(cat "$RUN_DIR/pid" 2>/dev/null || true)"
520
+ child="$(cat "$RUN_DIR/child" 2>/dev/null || true)"
521
+ self="$$"
522
+ parent="$PPID"
523
+ targets=""
524
+
525
+ is_pid() {
526
+ case "$1" in
527
+ ''|*[!0-9]*) return 1 ;;
528
+ *) [ "$1" -gt 1 ] 2>/dev/null ;;
529
+ esac
530
+ }
531
+
532
+ contains_pid() {
533
+ case " $1 " in
534
+ *" $2 "*) return 0 ;;
535
+ *) return 1 ;;
536
+ esac
537
+ }
538
+
539
+ add_pid() {
540
+ target="$1"
541
+ is_pid "$target" || return 0
542
+ [ "$target" = "$self" ] && return 0
543
+ [ "$target" = "$parent" ] && return 0
544
+ contains_pid "$targets" "$target" && return 0
545
+ targets="$targets $target"
546
+ }
547
+
548
+ ppid_of() {
549
+ awk '/^PPid:[[:space:]]*/ { print $2; exit }' "/proc/$1/status" 2>/dev/null || true
550
+ }
551
+
552
+ collect_marker_roots() {
553
+ marker_roots=""
554
+ for env in /proc/[0-9]*/environ; do
555
+ [ -r "$env" ] || continue
556
+ proc_dir="$(dirname "$env")"
557
+ proc_pid="$(basename "$proc_dir")"
558
+ is_pid "$proc_pid" || continue
559
+ if tr '\\000' '\\n' < "$env" 2>/dev/null | grep -Fqx "$CMD_MARKER"; then
560
+ add_pid "$proc_pid"
561
+ contains_pid "$marker_roots" "$proc_pid" || marker_roots="$marker_roots $proc_pid"
562
+ fi
563
+ done
564
+ printf '%s\\n' "$marker_roots"
565
+ }
566
+
567
+ collect_descendants() {
568
+ roots="$1"
569
+ known=""
570
+ for root in $roots; do
571
+ is_pid "$root" || continue
572
+ contains_pid "$known" "$root" || known="$known $root"
573
+ add_pid "$root"
574
+ done
575
+
576
+ changed=1
577
+ while [ "$changed" = 1 ]; do
578
+ changed=0
579
+ for status in /proc/[0-9]*/status; do
580
+ [ -r "$status" ] || continue
581
+ proc_dir="$(dirname "$status")"
582
+ proc_pid="$(basename "$proc_dir")"
583
+ is_pid "$proc_pid" || continue
584
+ contains_pid "$known" "$proc_pid" && continue
585
+ proc_ppid="$(ppid_of "$proc_pid")"
586
+ if is_pid "$proc_ppid" && contains_pid "$known" "$proc_ppid"; then
587
+ known="$known $proc_pid"
588
+ add_pid "$proc_pid"
589
+ changed=1
590
+ fi
591
+ done
592
+ done
593
+ }
594
+
595
+ collect_all_targets() {
596
+ targets=""
597
+ roots="$pid $child"
598
+ marker_roots="$(collect_marker_roots)"
599
+ roots="$roots $marker_roots"
600
+ collect_descendants "$roots"
601
+ printf '%s\\n' "$targets"
602
+ }
603
+
604
+ depth_of() {
605
+ depth=0
606
+ current="$1"
607
+ while is_pid "$current" && [ "$current" -gt 1 ] && [ "$depth" -lt 512 ]; do
608
+ current="$(ppid_of "$current")"
609
+ depth=$((depth + 1))
610
+ done
611
+ printf '%s\\n' "$depth"
612
+ }
613
+
614
+ kill_group() {
615
+ signal="$1"
616
+ target="$2"
617
+ is_pid "$target" || return 0
618
+ kill -"$signal" -- "-$target" 2>/dev/null || kill -"$signal" "-$target" 2>/dev/null || true
619
+ }
620
+
621
+ kill_targets_deepest_first() {
622
+ signal="$1"
623
+ target_list="$2"
624
+ for target in $(
625
+ for target in $target_list; do
626
+ [ -d "/proc/$target" ] || continue
627
+ printf '%s %s\\n' "$(depth_of "$target")" "$target"
628
+ done | sort -rn | awk '{ print $2 }'
629
+ ); do
630
+ is_pid "$target" || continue
631
+ [ -d "/proc/$target" ] || continue
632
+ kill -"$signal" "$target" 2>/dev/null || true
633
+ done
634
+ }
635
+
636
+ current_targets="$(collect_all_targets)"
637
+ kill_group TERM "$pid"
638
+ kill_group TERM "$child"
639
+ kill_targets_deepest_first TERM "$current_targets"
640
+ sleep 1
641
+ current_targets="$(collect_all_targets)"
642
+ kill_group KILL "$pid"
643
+ kill_group KILL "$child"
644
+ kill_targets_deepest_first KILL "$current_targets"
645
+ echo 143 > "$RUN_DIR/exit"
646
+ touch "$RUN_DIR/stopped"
647
+ `;
648
+ }
649
+ function safeId(value) {
650
+ return value.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 120) || 'unknown';
651
+ }
652
+ function sleep(ms) {
653
+ return new Promise((resolve) => setTimeout(resolve, ms));
654
+ }
655
+ function parseKeyValues(text) {
656
+ const result = {};
657
+ for (const line of text.split(/\r?\n/)) {
658
+ const index = line.indexOf('=');
659
+ if (index === -1)
660
+ continue;
661
+ result[line.slice(0, index)] = line.slice(index + 1);
662
+ }
663
+ return result;
664
+ }
665
+ function parseNullableInt(value) {
666
+ if (value == null || value === '')
667
+ return null;
668
+ const parsed = Number.parseInt(value, 10);
669
+ return Number.isFinite(parsed) ? parsed : null;
670
+ }
671
+ function parseStatus(value) {
672
+ if (value === 'running' || value === 'exited' || value === 'failed' || value === 'stopped')
673
+ return value;
674
+ return 'unknown';
675
+ }
@@ -0,0 +1,16 @@
1
+ export type FileDiff = {
2
+ file: string;
3
+ patch: string;
4
+ additions: number;
5
+ deletions: number;
6
+ };
7
+ export declare function createFileDiff(input: {
8
+ file: string;
9
+ oldContent: string;
10
+ newContent: string;
11
+ }): {
12
+ file: string;
13
+ patch: string;
14
+ additions: number;
15
+ deletions: number;
16
+ };