smart_box 0.1.1
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 +7 -0
- data/README.md +144 -0
- data/bin/smart_box +7 -0
- data/lib/smart_box/box.rb +381 -0
- data/lib/smart_box/cli.rb +412 -0
- data/lib/smart_box/command_result.rb +25 -0
- data/lib/smart_box/errors.rb +13 -0
- data/lib/smart_box/metadata.rb +124 -0
- data/lib/smart_box/modes/copy_mode.rb +106 -0
- data/lib/smart_box/modes/git_worktree_mode.rb +99 -0
- data/lib/smart_box/runner.rb +145 -0
- data/lib/smart_box/version.rb +3 -0
- data/lib/smart_box.rb +10 -0
- metadata +56 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBox
|
|
4
|
+
module Modes
|
|
5
|
+
class GitWorktreeMode
|
|
6
|
+
attr_reader :source_path, :workspace_path, :branch_name
|
|
7
|
+
|
|
8
|
+
def initialize(source_path:, workspace_path:, box_id:)
|
|
9
|
+
@source_path = File.expand_path(source_path)
|
|
10
|
+
@workspace_path = File.expand_path(workspace_path)
|
|
11
|
+
@box_id = box_id
|
|
12
|
+
@branch_name = "smart-box/#{box_id}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# --- entry points ---
|
|
16
|
+
|
|
17
|
+
def setup
|
|
18
|
+
validate_source!
|
|
19
|
+
ensure_branch!
|
|
20
|
+
create_worktree!
|
|
21
|
+
init_checkpoint!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def teardown
|
|
25
|
+
remove_worktree! if worktree_exists?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_source!
|
|
31
|
+
unless Dir.exist?(@source_path)
|
|
32
|
+
raise SmartBox::Error, "Source directory not found: #{@source_path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless Dir.exist?(File.join(@source_path, ".git"))
|
|
36
|
+
raise SmartBox::Error, "Source is not a git repository. git-worktree mode requires a git repo."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if source is clean
|
|
40
|
+
unless source_clean?
|
|
41
|
+
raise DirtySourceError,
|
|
42
|
+
"Source project has uncommitted changes.\n" \
|
|
43
|
+
"Commit or stash your changes before creating a git-worktree box, " \
|
|
44
|
+
"or use copy mode instead."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def source_clean?
|
|
49
|
+
Dir.chdir(@source_path) do
|
|
50
|
+
`git status --porcelain 2>/dev/null`.strip.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
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
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
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
|
|
68
|
+
|
|
69
|
+
unless Dir.exist?(@workspace_path)
|
|
70
|
+
raise SmartBox::Error, "Failed to create git worktree at #{@workspace_path}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
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
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
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
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
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
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require_relative "command_result"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module SmartBox
|
|
11
|
+
class Runner
|
|
12
|
+
# Simple dangerous command patterns (string matching per design spec §12.1)
|
|
13
|
+
DANGEROUS_PATTERNS = [
|
|
14
|
+
/\brm\s+-rf\s+\//,
|
|
15
|
+
/\bmkfs\b/,
|
|
16
|
+
/\bdd\s+if=/,
|
|
17
|
+
/\bshutdown\b/,
|
|
18
|
+
/\breboot\b/,
|
|
19
|
+
/\bsudo\b/,
|
|
20
|
+
/\bchmod\s+-R\s+777\s+\//,
|
|
21
|
+
/\bchown\s+-R\b/
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :box_id, :workspace_path, :logs_dir
|
|
25
|
+
|
|
26
|
+
def initialize(box_id:, workspace_path:, logs_dir:)
|
|
27
|
+
@box_id = box_id
|
|
28
|
+
@workspace_path = File.expand_path(workspace_path)
|
|
29
|
+
@logs_dir = File.expand_path(logs_dir)
|
|
30
|
+
@command_index = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# --- main entry point ---
|
|
34
|
+
|
|
35
|
+
def run(command, env: {}, timeout: nil, allow_dangerous: false)
|
|
36
|
+
unless allow_dangerous
|
|
37
|
+
check_dangerous!(command)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
started_at = Time.now
|
|
41
|
+
@command_index += 1
|
|
42
|
+
idx = @command_index
|
|
43
|
+
|
|
44
|
+
result = execute_command(command, env, timeout, started_at, idx)
|
|
45
|
+
write_logs(command, result, idx)
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def check_dangerous!(command)
|
|
53
|
+
DANGEROUS_PATTERNS.each do |pattern|
|
|
54
|
+
if command.match?(pattern)
|
|
55
|
+
raise DangerousCommandError,
|
|
56
|
+
"Dangerous command detected: #{command.inspect}\n" \
|
|
57
|
+
"Use --allow-dangerous to bypass this check."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def execute_command(command, env, timeout, started_at, idx)
|
|
63
|
+
stdout_path = File.join(@logs_dir, "#{format('%04d', idx)}.stdout.log")
|
|
64
|
+
stderr_path = File.join(@logs_dir, "#{format('%04d', idx)}.stderr.log")
|
|
65
|
+
|
|
66
|
+
FileUtils.mkdir_p(@logs_dir)
|
|
67
|
+
|
|
68
|
+
stdout = ""
|
|
69
|
+
stderr = ""
|
|
70
|
+
exit_code = nil
|
|
71
|
+
|
|
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
|
+
end
|
|
79
|
+
exit_code = status.exitstatus
|
|
80
|
+
rescue Errno::ENOENT => e
|
|
81
|
+
stderr = "Command not found: #{e.message}"
|
|
82
|
+
exit_code = 127
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
ended_at = Time.now
|
|
86
|
+
|
|
87
|
+
File.write(stdout_path, stdout)
|
|
88
|
+
File.write(stderr_path, stderr)
|
|
89
|
+
|
|
90
|
+
CommandResult.new(
|
|
91
|
+
command: command,
|
|
92
|
+
cwd: @workspace_path,
|
|
93
|
+
stdout: stdout,
|
|
94
|
+
stderr: stderr,
|
|
95
|
+
exit_code: exit_code,
|
|
96
|
+
started_at: started_at,
|
|
97
|
+
ended_at: ended_at
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write_logs(command, result, idx)
|
|
102
|
+
write_human_log(command, result)
|
|
103
|
+
write_jsonl_log(command, result, idx)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_human_log(command, result)
|
|
107
|
+
log_path = File.join(@logs_dir, "commands.log")
|
|
108
|
+
FileUtils.mkdir_p(@logs_dir)
|
|
109
|
+
|
|
110
|
+
entry = <<~ENTRY
|
|
111
|
+
[#{result.started_at.strftime('%Y-%m-%d %H:%M:%S')}] RUN #{command}
|
|
112
|
+
cwd: #{result.cwd}
|
|
113
|
+
exit_code: #{result.exit_code}
|
|
114
|
+
|
|
115
|
+
STDOUT:
|
|
116
|
+
#{result.stdout.strip.empty? ? '(empty)' : result.stdout.strip}
|
|
117
|
+
|
|
118
|
+
STDERR:
|
|
119
|
+
#{result.stderr.strip.empty? ? '(empty)' : result.stderr.strip}
|
|
120
|
+
|
|
121
|
+
ENTRY
|
|
122
|
+
|
|
123
|
+
File.open(log_path, "a") { |f| f.write(entry) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def write_jsonl_log(command, result, idx)
|
|
127
|
+
log_path = File.join(@logs_dir, "commands.jsonl")
|
|
128
|
+
FileUtils.mkdir_p(@logs_dir)
|
|
129
|
+
|
|
130
|
+
entry = {
|
|
131
|
+
"time" => result.started_at.iso8601,
|
|
132
|
+
"box_id" => @box_id,
|
|
133
|
+
"event" => "command_finished",
|
|
134
|
+
"command" => command,
|
|
135
|
+
"cwd" => result.cwd,
|
|
136
|
+
"exit_code" => result.exit_code,
|
|
137
|
+
"elapsed_ms" => result.elapsed_ms,
|
|
138
|
+
"stdout_path" => "logs/#{format('%04d', idx)}.stdout.log",
|
|
139
|
+
"stderr_path" => "logs/#{format('%04d', idx)}.stderr.log"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
File.open(log_path, "a") { |f| f.puts(JSON.generate(entry)) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/smart_box.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "smart_box/version"
|
|
4
|
+
require_relative "smart_box/errors"
|
|
5
|
+
require_relative "smart_box/metadata"
|
|
6
|
+
require_relative "smart_box/command_result"
|
|
7
|
+
require_relative "smart_box/runner"
|
|
8
|
+
require_relative "smart_box/modes/copy_mode"
|
|
9
|
+
require_relative "smart_box/modes/git_worktree_mode"
|
|
10
|
+
require_relative "smart_box/box"
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: smart_box
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SmartBox Team
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
smart_box is a local sandbox system for Coding Agents. It allows agents to
|
|
14
|
+
perform file modifications, command execution, dependency installation,
|
|
15
|
+
experimental fixes, code generation, diff viewing, checkpoint rollback,
|
|
16
|
+
and patch export without directly polluting the real project directory.
|
|
17
|
+
executables:
|
|
18
|
+
- smart_box
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- README.md
|
|
23
|
+
- bin/smart_box
|
|
24
|
+
- lib/smart_box.rb
|
|
25
|
+
- lib/smart_box/box.rb
|
|
26
|
+
- lib/smart_box/cli.rb
|
|
27
|
+
- lib/smart_box/command_result.rb
|
|
28
|
+
- lib/smart_box/errors.rb
|
|
29
|
+
- lib/smart_box/metadata.rb
|
|
30
|
+
- lib/smart_box/modes/copy_mode.rb
|
|
31
|
+
- lib/smart_box/modes/git_worktree_mode.rb
|
|
32
|
+
- lib/smart_box/runner.rb
|
|
33
|
+
- lib/smart_box/version.rb
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata:
|
|
37
|
+
source_code_uri: https://github.com/smart-box/smart_box
|
|
38
|
+
changelog_uri: https://github.com/smart-box/smart_box/blob/main/CHANGELOG.md
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 3.0.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 4.0.13
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: A local reversible sandbox system for agent task execution
|
|
56
|
+
test_files: []
|