Class: Kward::Workspace

Inherits:
Object
  • Object
show all
Defined in:
lib/kward/workspace.rb

Constant Summary collapse

MAX_FILE_BYTES =
256 * 1024
MAX_READ_OUTPUT_BYTES =
50 * 1024
MAX_READ_OUTPUT_LINES =
2_000
MAX_COMMAND_OUTPUT_BYTES =
20 * 1024
MAX_EDIT_DIFF_BYTES =
8 * 1024
DEFAULT_COMMAND_TIMEOUT_SECONDS =
30

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES) ⇒ Workspace

Returns a new instance of Workspace.



15
16
17
18
19
20
21
# File 'lib/kward/workspace.rb', line 15

def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES)
  @root = Pathname.new(root).realpath
  @max_file_bytes = max_file_bytes
  @max_read_output_bytes = max_read_output_bytes
  @max_read_output_lines = max_read_output_lines
  @max_command_output_bytes = max_command_output_bytes
end

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



23
24
25
# File 'lib/kward/workspace.rb', line 23

def root
  @root
end

Instance Method Details

#edit_file(path, edits, read_paths:) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/kward/workspace.rb', line 68

def edit_file(path, edits, read_paths:)
  resolved = workspace_path(path)
  return "Error: not a file: #{path}" unless File.file?(resolved)
  return "Error: existing file must be read before editing: #{path}" unless read_paths.include?(resolved)

  size = File.size(resolved)
  return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes

  content = File.read(resolved)
  result = apply_edits(path, content, edits)
  return result[:error] if result[:error]

  File.write(resolved, result[:content])
  "Edited #{path}: replaced #{result[:count]} block(s)\n#{truncated_diff(path, content, result[:content])}"
rescue SecurityError, Errno::ENOENT => e
  "Error: #{e.message}"
end

#list_directory(path) ⇒ Object



25
26
27
28
29
30
31
32
33
34
# File 'lib/kward/workspace.rb', line 25

def list_directory(path)
  resolved = workspace_path(path)
  return "Error: not a directory: #{path}" unless File.directory?(resolved)

  Dir.children(resolved).sort.map do |entry|
    File.directory?(File.join(resolved, entry)) ? "#{entry}/" : entry
  end.join("\n")
rescue SecurityError, Errno::ENOENT => e
  "Error: #{e.message}"
end

#read_file(path, offset: nil, limit: nil) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
# File 'lib/kward/workspace.rb', line 36

def read_file(path, offset: nil, limit: nil)
  resolved = workspace_path(path)
  return "Error: not a file: #{path}" unless File.file?(resolved)

  size = File.size(resolved)
  return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes

  read_file_slice(File.read(resolved), offset: offset, limit: limit)
rescue SecurityError, Errno::ENOENT => e
  "Error: #{e.message}"
end

#resolved_path(path) ⇒ Object



116
117
118
# File 'lib/kward/workspace.rb', line 116

def resolved_path(path)
  workspace_path(path)
end

#run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/kward/workspace.rb', line 86

def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
  command = command.to_s.strip
  return "Error: command is required" if command.empty?

  timeout_seconds = timeout_seconds.to_i
  timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
  cancellation&.raise_if_cancelled!

  Open3.popen3(command, chdir: @root.to_s) do |stdin, stdout, stderr, wait_thread|
    stdin.close
    stdout_reader = Thread.new { stdout.read }
    stderr_reader = Thread.new { stderr.read }
    cancellation&.on_cancel { terminate_process(wait_thread.pid) }
    status = wait_for_process(wait_thread, timeout_seconds, cancellation)

    output = +"Exit status: #{status.exitstatus}\n"
    output << "\nSTDOUT:\n#{stdout_reader.value}" unless stdout_reader.value.empty?
    output << "\nSTDERR:\n#{stderr_reader.value}" unless stderr_reader.value.empty?
    truncate_output(output)
  rescue Timeout::Error
    terminate_process(wait_thread.pid)
    "Error: command timed out after #{timeout_seconds} seconds"
  ensure
    stdout_reader&.kill if stdout_reader&.alive?
    stderr_reader&.kill if stderr_reader&.alive?
  end
rescue Errno::ENOENT, ArgumentError => e
  "Error: #{e.message}"
end

#write_file(path, content, read_paths:) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/kward/workspace.rb', line 48

def write_file(path, content, read_paths:)
  resolved = workspace_write_path(path)

  if File.exist?(resolved) && !read_paths.include?(resolved)
    return "Error: existing file must be read before writing: #{path}"
  end

  if block_given? && !yield(relative_path(resolved), content.bytesize)
    return "Declined: write_file was not approved for #{path}"
  end

  old_content = File.exist?(resolved) ? File.read(resolved) : nil
  File.write(resolved, content)
  output = "Wrote #{content.bytesize} bytes to #{path}"
  output << "\n#{truncated_diff(path, old_content, content)}" if old_content && old_content != content
  output
rescue SecurityError, Errno::ENOENT => e
  "Error: #{e.message}"
end