smart_box 0.1.1 → 0.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdd527c54db2a72e0d40528a4fb248a712f06a8f28bd11e7b81c995963e1a4e1
4
- data.tar.gz: b277525806bbccc5ad7a979733d0e9fcaa7ad483f8c8910e5d5105ea78f94712
3
+ metadata.gz: c0b203b0fd0f6c0c5b2736ac7249ed7d2cca87fa8ea7e457384082f96acbeae8
4
+ data.tar.gz: 22aa9f3e46150e1c2c787c9b5de6c094bfe3b85e3bf556ace43ee10f84a9061e
5
5
  SHA512:
6
- metadata.gz: 61675f220c4dda1cae98b9091201057351b9abd65dfe4d4c4c906aa4be4e926e0091619079034a88fdb8209c17e7fc434014b432bb8397b869fcddd413c40ca2
7
- data.tar.gz: b14f1b4116d23c01e540eac1275a5048e8decc279034ee33fde8f5b374314402fb2ec0a291a20762cef9e553a1c992425bdb9c19509f2b7bc51ec21371b8c268
6
+ metadata.gz: d2a894d704e2db93123bfb1a5d63fcfd643e33667760e1e0a5d1fdce0508d79f6e5d62802e1fb504ae668889b71e8b142b6a6a9f3faf6f18f96102b274d08541
7
+ data.tar.gz: 256bc4f7134d0356df2318602c8798434f84b089c5e8b601a309bb5b786ac9c1fb5191406c22c064a8d9f05d01fde797b2c834583495ac41b5a387757a0b2f68
data/lib/smart_box/box.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "time"
5
+ require "open3"
5
6
  require_relative "errors"
6
7
  require_relative "metadata"
7
8
  require_relative "modes/copy_mode"
@@ -127,12 +128,11 @@ module SmartBox
127
128
  def git_status_short
128
129
  return [] unless Dir.exist?(@workspace_path)
129
130
 
130
- Dir.chdir(@workspace_path) do
131
- out = `git status --porcelain 2>/dev/null`
132
- return [] unless $?.success?
131
+ out, _err, status = Open3.capture3("git", "status", "--porcelain",
132
+ chdir: @workspace_path)
133
+ return [] unless status.success?
133
134
 
134
- out.lines.map(&:chomp).reject(&:empty?)
135
- end
135
+ out.lines.map(&:chomp).reject(&:empty?)
136
136
  end
137
137
 
138
138
  def run(command, env: {}, timeout: nil, allow_dangerous: false)
@@ -149,10 +149,9 @@ module SmartBox
149
149
  def checkpoint(name)
150
150
  raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
151
151
 
152
- Dir.chdir(@workspace_path) do
153
- system("git", "add", "-A", out: File::NULL, err: File::NULL)
154
- system("git", "commit", "--allow-empty", "-m", name, out: File::NULL, err: File::NULL)
155
- end
152
+ system("git", "add", "-A", chdir: @workspace_path, out: File::NULL, err: File::NULL)
153
+ system("git", "commit", "--allow-empty", "-m", name,
154
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
156
155
 
157
156
  commit = git_latest_commit
158
157
  cp_id = "cp-#{format('%03d', (@metadata.checkpoints.size + 1))}"
@@ -181,10 +180,10 @@ module SmartBox
181
180
  cp = @metadata.checkpoints.detect { |c| c["id"] == checkpoint_id }
182
181
  raise CheckpointNotFoundError, "Checkpoint '#{checkpoint_id}' not found" unless cp
183
182
 
184
- Dir.chdir(@workspace_path) do
185
- system("git", "reset", "--hard", cp["git_commit"], out: File::NULL, err: File::NULL)
186
- system("git", "clean", "-fd", out: File::NULL, err: File::NULL)
187
- end
183
+ system("git", "reset", "--hard", cp["git_commit"],
184
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
185
+ system("git", "clean", "-fd",
186
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
188
187
 
189
188
  @metadata.load!
190
189
  @metadata.updated_at = Time.now.utc.iso8601
@@ -196,25 +195,25 @@ module SmartBox
196
195
  def diff(from: nil, to: nil)
197
196
  raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
198
197
 
199
- Dir.chdir(@workspace_path) do
200
- # Stage everything so untracked files appear in diff
201
- system("git", "add", "-A", out: File::NULL, err: File::NULL)
198
+ system("git", "add", "-A", chdir: @workspace_path, out: File::NULL, err: File::NULL)
202
199
 
203
- from_commit = if from
204
- resolve_checkpoint_commit(from)
205
- else
206
- init = @metadata.checkpoints.first
207
- init ? init["git_commit"] : "HEAD~1"
208
- end
200
+ from_commit = if from
201
+ resolve_checkpoint_commit(from)
202
+ else
203
+ init = @metadata.checkpoints.first
204
+ init ? init["git_commit"] : "HEAD~1"
205
+ end
209
206
 
210
- to_commit = to ? resolve_checkpoint_commit(to) : nil
207
+ to_commit = to ? resolve_checkpoint_commit(to) : nil
211
208
 
212
- if to_commit
213
- `git diff #{from_commit} #{to_commit} 2>/dev/null`
214
- else
215
- `git diff --cached #{from_commit} 2>/dev/null`
216
- end
209
+ if to_commit
210
+ out, _err, _st = Open3.capture3("git", "diff", from_commit, to_commit,
211
+ chdir: @workspace_path)
212
+ else
213
+ out, _err, _st = Open3.capture3("git", "diff", "--cached", from_commit,
214
+ chdir: @workspace_path)
217
215
  end
216
+ out.to_s
218
217
  end
219
218
 
220
219
  def export_patch(output:, from: nil, to: nil)
@@ -249,36 +248,29 @@ module SmartBox
249
248
  backup_patch_path = File.join(@box_dir, "patches", "backup.patch")
250
249
  FileUtils.mkdir_p(File.join(@box_dir, "patches"))
251
250
 
252
- Dir.chdir(@source_path) do
253
- backup = `git diff 2>/dev/null`
254
- File.write(backup_patch_path, backup) unless backup.empty?
255
- end
251
+ backup, _err, _st = Open3.capture3("git", "diff", chdir: @source_path)
252
+ File.write(backup_patch_path, backup) unless backup.empty?
256
253
 
257
254
  # Apply the patch
258
- Dir.chdir(@source_path) do
259
- IO.popen(["git", "apply", "-v"], "w") do |io|
260
- io.write(patch_content)
261
- end
262
-
263
- unless $?.success?
264
- raise PatchApplyError, "Failed to apply patch. The source project may have conflicts."
265
- end
266
-
267
- # Show what changed
268
- result_diff = `git diff 2>/dev/null`
269
- result_diff
255
+ _apply_out, _apply_err, status =
256
+ Open3.capture3("git", "apply", "-v",
257
+ stdin_data: patch_content, chdir: @source_path)
258
+
259
+ unless status.success?
260
+ raise PatchApplyError, "Failed to apply patch. The source project may have conflicts."
270
261
  end
262
+
263
+ # Show what changed
264
+ result_diff, _err2, _st2 = Open3.capture3("git", "diff", chdir: @source_path)
265
+ result_diff
271
266
  else
272
267
  # Non-git source: apply patch with patch command
273
268
  backup_patch_path = File.join(@box_dir, "patches", "backup.diff")
274
269
  FileUtils.mkdir_p(File.join(@box_dir, "patches"))
275
270
  File.write(backup_patch_path, "backup not available for non-git source")
276
271
 
277
- Dir.chdir(@source_path) do
278
- IO.popen(["patch", "-p1", "-N", "-r", "/dev/null"], "w") do |io|
279
- io.write(patch_content)
280
- end
281
- end
272
+ Open3.capture3("patch", "-p1", "-N", "-r", "/dev/null",
273
+ stdin_data: patch_content, chdir: @source_path)
282
274
 
283
275
  "Patch applied to non-git source at #{@source_path}"
284
276
  end
@@ -299,7 +291,10 @@ module SmartBox
299
291
 
300
292
  def source_clean?
301
293
  return true unless Dir.exist?(File.join(@source_path, ".git"))
302
- Dir.chdir(@source_path) { `git status --porcelain 2>/dev/null`.strip.empty? }
294
+
295
+ out, _err, _st = Open3.capture3("git", "status", "--porcelain",
296
+ chdir: @source_path)
297
+ out.strip.empty?
303
298
  end
304
299
 
305
300
  private
@@ -338,27 +333,27 @@ module SmartBox
338
333
  # wrongly return "none". File.exist? is true for both files and dirs.
339
334
  return "none" unless File.exist?(File.join(@workspace_path, ".git"))
340
335
 
341
- Dir.chdir(@workspace_path) do
342
- `git rev-parse HEAD 2>/dev/null`.strip
343
- end
336
+ out, _err, _st = Open3.capture3("git", "rev-parse", "HEAD",
337
+ chdir: @workspace_path)
338
+ out.strip
344
339
  end
345
340
 
346
341
  def source_git_commit
347
342
  git_dir = File.join(@source_path, ".git")
348
343
  return "none" unless Dir.exist?(git_dir)
349
344
 
350
- Dir.chdir(@source_path) do
351
- `git rev-parse HEAD 2>/dev/null`.strip
352
- end
345
+ out, _err, _st = Open3.capture3("git", "rev-parse", "HEAD",
346
+ chdir: @source_path)
347
+ out.strip
353
348
  end
354
349
 
355
350
  def source_git_branch
356
351
  git_dir = File.join(@source_path, ".git")
357
352
  return "none" unless Dir.exist?(git_dir)
358
353
 
359
- Dir.chdir(@source_path) do
360
- `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
361
- end
354
+ out, _err, _st = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD",
355
+ chdir: @source_path)
356
+ out.strip
362
357
  end
363
358
 
364
359
  def resolve_checkpoint_commit(checkpoint_id)
@@ -93,13 +93,14 @@ module SmartBox
93
93
  end
94
94
 
95
95
  def init_git!
96
- Dir.chdir(@workspace_path) do
97
- system("git", "init", out: File::NULL, err: File::NULL)
98
- system("git", "config", "user.email", "smart_box@localhost", out: File::NULL, err: File::NULL)
99
- system("git", "config", "user.name", "smart_box", out: File::NULL, err: File::NULL)
100
- system("git", "add", "-A", out: File::NULL, err: File::NULL)
101
- system("git", "commit", "-m", "smart_box initial checkpoint", out: File::NULL, err: File::NULL)
102
- end
96
+ system("git", "init", chdir: @workspace_path, out: File::NULL, err: File::NULL)
97
+ system("git", "config", "user.email", "smart_box@localhost",
98
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
99
+ system("git", "config", "user.name", "smart_box",
100
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
101
+ system("git", "add", "-A", chdir: @workspace_path, out: File::NULL, err: File::NULL)
102
+ system("git", "commit", "-m", "smart_box initial checkpoint",
103
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
103
104
  end
104
105
  end
105
106
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module SmartBox
4
6
  module Modes
5
7
  class GitWorktreeMode
@@ -46,25 +48,23 @@ module SmartBox
46
48
  end
47
49
 
48
50
  def source_clean?
49
- Dir.chdir(@source_path) do
50
- `git status --porcelain 2>/dev/null`.strip.empty?
51
- end
51
+ out, _err, _st = Open3.capture3("git", "status", "--porcelain",
52
+ chdir: @source_path)
53
+ out.strip.empty?
52
54
  end
53
55
 
54
56
  def ensure_branch!
55
- Dir.chdir(@source_path) do
56
- branches = `git branch --list #{@branch_name} 2>/dev/null`.strip
57
- if branches.empty?
58
- system("git", "branch", @branch_name, out: File::NULL, err: File::NULL)
59
- end
57
+ branches, _err, _st = Open3.capture3("git", "branch", "--list", @branch_name,
58
+ chdir: @source_path)
59
+ if branches.strip.empty?
60
+ system("git", "branch", @branch_name,
61
+ chdir: @source_path, out: File::NULL, err: File::NULL)
60
62
  end
61
63
  end
62
64
 
63
65
  def create_worktree!
64
- Dir.chdir(@source_path) do
65
- system("git", "worktree", "add", @workspace_path, @branch_name,
66
- out: File::NULL, err: File::NULL)
67
- end
66
+ system("git", "worktree", "add", @workspace_path, @branch_name,
67
+ chdir: @source_path, out: File::NULL, err: File::NULL)
68
68
 
69
69
  unless Dir.exist?(@workspace_path)
70
70
  raise SmartBox::Error, "Failed to create git worktree at #{@workspace_path}"
@@ -72,27 +72,22 @@ module SmartBox
72
72
  end
73
73
 
74
74
  def init_checkpoint!
75
- Dir.chdir(@workspace_path) do
76
- system("git", "add", "-A", out: File::NULL, err: File::NULL)
77
- system("git", "commit", "--allow-empty", "-m", "smart_box initial checkpoint",
78
- out: File::NULL, err: File::NULL)
79
- end
75
+ system("git", "add", "-A", chdir: @workspace_path, out: File::NULL, err: File::NULL)
76
+ system("git", "commit", "--allow-empty", "-m", "smart_box initial checkpoint",
77
+ chdir: @workspace_path, out: File::NULL, err: File::NULL)
80
78
  end
81
79
 
82
80
  def remove_worktree!
83
- Dir.chdir(@source_path) do
84
- system("git", "worktree", "remove", @workspace_path, "--force",
85
- out: File::NULL, err: File::NULL)
86
- system("git", "branch", "-D", @branch_name,
87
- out: File::NULL, err: File::NULL)
88
- end
81
+ system("git", "worktree", "remove", @workspace_path, "--force",
82
+ chdir: @source_path, out: File::NULL, err: File::NULL)
83
+ system("git", "branch", "-D", @branch_name,
84
+ chdir: @source_path, out: File::NULL, err: File::NULL)
89
85
  end
90
86
 
91
87
  def worktree_exists?
92
- Dir.chdir(@source_path) do
93
- worktrees = `git worktree list --porcelain 2>/dev/null`
94
- worktrees.include?(@workspace_path)
95
- end
88
+ worktrees, _err, _st = Open3.capture3("git", "worktree", "list", "--porcelain",
89
+ chdir: @source_path)
90
+ worktrees.include?(@workspace_path)
96
91
  end
97
92
  end
98
93
  end
@@ -4,6 +4,7 @@ require "open3"
4
4
  require "json"
5
5
  require "time"
6
6
  require "shellwords"
7
+ require "fileutils"
7
8
  require_relative "command_result"
8
9
  require_relative "errors"
9
10
 
@@ -21,6 +22,11 @@ module SmartBox
21
22
  /\bchown\s+-R\b/
22
23
  ].freeze
23
24
 
25
+ # Exit code used when a command exceeds its timeout (GNU timeout convention).
26
+ TIMEOUT_EXIT_CODE = 124
27
+ # Grace period in seconds between TERM and KILL when terminating a process group.
28
+ KILL_GRACE_SECONDS = 2
29
+
24
30
  attr_reader :box_id, :workspace_path, :logs_dir
25
31
 
26
32
  def initialize(box_id:, workspace_path:, logs_dir:)
@@ -69,17 +75,25 @@ module SmartBox
69
75
  stderr = ""
70
76
  exit_code = nil
71
77
 
72
- Dir.chdir(@workspace_path) do
73
- args = Shellwords.split(command)
74
- if env && !env.empty?
75
- stdout, stderr, status = Open3.capture3(env, *args, chdir: @workspace_path)
76
- else
77
- stdout, stderr, status = Open3.capture3(*args, chdir: @workspace_path)
78
+ args = Shellwords.split(command)
79
+ # Never switch the parent process directory: all execution is confined to
80
+ # the workspace via Open3's `chdir:` option. This avoids Ruby's
81
+ # process-wide `conflicting chdir during another chdir block` error when
82
+ # boxes run in background threads alongside other threads in the process.
83
+ if timeout && timeout.to_f > 0
84
+ stdout, stderr, exit_code = execute_with_timeout(args, env, timeout.to_f)
85
+ else
86
+ begin
87
+ if env && !env.empty?
88
+ stdout, stderr, status = Open3.capture3(env, *args, chdir: @workspace_path)
89
+ else
90
+ stdout, stderr, status = Open3.capture3(*args, chdir: @workspace_path)
91
+ end
92
+ exit_code = status.exitstatus
93
+ rescue Errno::ENOENT => e
94
+ stderr = "Command not found: #{e.message}"
95
+ exit_code = 127
78
96
  end
79
- exit_code = status.exitstatus
80
- rescue Errno::ENOENT => e
81
- stderr = "Command not found: #{e.message}"
82
- exit_code = 127
83
97
  end
84
98
 
85
99
  ended_at = Time.now
@@ -98,6 +112,55 @@ module SmartBox
98
112
  )
99
113
  end
100
114
 
115
+ # Runs a command with a hard timeout by spawning it in its own process group
116
+ # (pgroup: true) and killing the whole group on timeout. This kills child
117
+ # processes too (e.g. find/rsync), unlike Ruby's Timeout.timeout.
118
+ def execute_with_timeout(args, env, timeout_s)
119
+ popen_args = env && !env.empty? ? [env, *args] : args
120
+ Open3.popen3(*popen_args, chdir: @workspace_path, pgroup: true) do |stdin, out, err, wait_thr|
121
+ stdin.close
122
+
123
+ # Drain stdout/stderr concurrently to avoid pipe buffer deadlocks.
124
+ out_reader = Thread.new { out.read }
125
+ err_reader = Thread.new { err.read }
126
+
127
+ waiter = Thread.new { wait_thr.join }
128
+ timed_out = false
129
+ unless waiter.join(timeout_s)
130
+ timed_out = true
131
+ kill_process_group!(wait_thr)
132
+ end
133
+ waiter.kill if waiter.alive?
134
+
135
+ stdout = out_reader.value
136
+ stderr = err_reader.value
137
+ exit_code = timed_out ? TIMEOUT_EXIT_CODE : wait_thr.value.exitstatus
138
+
139
+ if timed_out
140
+ marker = "[timeout] command exceeded #{format('%g', timeout_s)}s and was killed (exit #{TIMEOUT_EXIT_CODE})"
141
+ stderr = stderr.to_s.empty? ? marker : "#{stderr.rstrip}\n#{marker}"
142
+ end
143
+
144
+ [stdout, stderr, exit_code]
145
+ end
146
+ rescue Errno::ENOENT => e
147
+ ["", "Command not found: #{e.message}", 127]
148
+ end
149
+
150
+ # Sends TERM to the whole process group, waits a grace period, then KILL.
151
+ def kill_process_group!(wait_thr)
152
+ %w[TERM KILL].each_with_index do |signal, idx|
153
+ break unless wait_thr.alive?
154
+
155
+ begin
156
+ Process.kill(signal, -wait_thr.pid)
157
+ rescue Errno::ESRCH, Errno::EPERM
158
+ break # Process group is already gone (or we lack permission).
159
+ end
160
+ wait_thr.join(KILL_GRACE_SECONDS) if idx.zero?
161
+ end
162
+ end
163
+
101
164
  def write_logs(command, result, idx)
102
165
  write_human_log(command, result)
103
166
  write_jsonl_log(command, result, idx)
@@ -1,3 +1,3 @@
1
1
  module SmartBox
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_box
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - SmartBox Team
@@ -50,7 +50,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
50
50
  - !ruby/object:Gem::Version
51
51
  version: '0'
52
52
  requirements: []
53
- rubygems_version: 4.0.13
53
+ rubygems_version: 4.0.16
54
54
  specification_version: 4
55
55
  summary: A local reversible sandbox system for agent task execution
56
56
  test_files: []