Files
opencode-rails/lib/opencode/sandbox.rb
Ajay Krishnan 69cfff55b2 Port lib/opencode/rails/ source files; strip Rails.event/Rails.error
Eleven source files moved from ajent-rails:lib/opencode/rails/ to
opencode-rails:lib/opencode/ (flat layout — modules are Opencode::*, not
Opencode::Rails::*; matches opencode-ruby).

  artifact.rb           63 LOC
  exchange.rb           77 LOC
  impostor.rb           48 LOC
  message_artifacts.rb 133 LOC
  sandbox_file.rb       81 LOC
  sandbox.rb            71 LOC
  session.rb           168 LOC
  tool_display.rb      423 LOC
  transform.rb          77 LOC
  turn.rb              642 LOC
  uploaded_files_prompt.rb 85 LOC
  ----
  total              1,868 LOC

Surgical Rails strips:

  exchange.rb:
    Rails.event.notify(name, payload)
      -> Opencode::Instrumentation.instrument(name, payload) { }

  message_artifacts.rb (1 call), turn.rb (6 calls):
    Rails.error.report(error, **opts)
      -> Opencode::ErrorReporter.report(error, **opts)

Comments/docstrings referencing Rails.error.report / Rails.event left
in place — they document how to wire the host adapter.

ActiveSupport core_ext requires expanded in lib/opencode-rails.rb to
cover Numeric#seconds, Hash#deep_stringify_keys, String#squish/truncate,
String#demodulize. Bundle install + smoke load confirms all 12
gem-provided constants resolve cleanly.
2026-05-20 05:14:00 -07:00

72 lines
2.4 KiB
Ruby

# frozen_string_literal: true
module Opencode
# The per-user (or per-trip) sandbox directory the agent's container
# writes into. A first-class noun rather than a path-string with
# primitives sprinkled around the codebase: the Sandbox knows its
# own path, knows how to walk itself, knows what "fresh enough" means
# for a given turn, and yields SandboxFile values that carry their
# own safety predicate.
#
# Used by Opencode::MessageArtifacts. Construct one with the path,
# then ask it for `files(after:)` where `after` is the user message's
# created_at time (minus CUTOFF_SLACK). Files older than the cutoff
# are stale leftovers from a previous turn — never attached.
class Sandbox
# Two-second slack absorbs clock skew between the Rails app and the
# per-user OpenCode container. Without it, a file written by the
# container in the same wall-clock second as the user message could
# be (mtime < created_at) and get rejected.
CUTOFF_SLACK = 2.seconds
attr_reader :path
def initialize(path:, max_file_bytes: Opencode::ResponseParser::MAX_ARTIFACT_SIZE)
@path = path
@max_file_bytes = max_file_bytes
end
def exists?
path.present? && Dir.exist?(path)
end
# Yields SandboxFile values for every file in the sandbox that
# passes its own #safe? predicate AND was modified after the cutoff.
# When `after:` is nil (callers without a user_message handle, e.g.
# AIGL on certain finalize paths), no mtime filter is applied —
# only safety + filetype.
def files(after: nil)
return enum_for(:files, after: after) unless block_given?
return unless exists?
cutoff = after && (after.to_time - CUTOFF_SLACK)
Dir.glob(File.join(path, "*")).each do |entry|
next unless File.file?(entry)
next if cutoff && File.mtime(entry) < cutoff
file = SandboxFile.new(
path: entry,
sandbox_prefix: prefix,
max_bytes: @max_file_bytes
)
next unless file.safe?
yield file
end
end
def file(basename, after: nil)
files(after: after).find { |f| f.basename == basename }
end
private
# Separator-terminated prefix so /sandbox-1 doesn't false-positive
# on /sandbox-10/foo when SandboxFile checks realpath containment.
def prefix
@prefix ||= File.join(path, "")
end
end
end