Initial public release v0.0.1.alpha2
opencode-rails — production-grade Rails integration for OpenCode. Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle (idempotent ensure!/recreate!/abort! with row-level locks), a Turn orchestrator driving the Reply state machine and recovering from session-not-found, an artifact pipeline backed by ActiveStorage, sandbox seeding, and tool-display value objects for Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants production-grade OpenCode streaming without rolling boilerplate. What this version ships: - Opencode::Session (AR-coupled lifecycle, row-level locks) - Opencode::Turn (Reply state machine, session-not-found recovery) - Opencode::Exchange (one turn = one request/response unit) - Opencode::Impostor (deterministic mock for tests) - Opencode::Sandbox / SandboxFile (per-session FS scratch space) - Opencode::Transform (host-rendered artifact pipeline) - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed) - Opencode::UploadedFilesPrompt (system-prompt builder) - Opencode::ToolDisplay (Turbo Stream value objects) - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.) - examples/rails_integration.rb — canonical wiring blueprint 53 smoke tests. CI on Ruby 3.2/3.3/3.4. Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2, activerecord/activestorage/activesupport >= 7.1, < 9.0. See CHANGELOG.md for the alpha1 -> alpha2 delta.
This commit is contained in:
81
lib/opencode/sandbox_file.rb
Normal file
81
lib/opencode/sandbox_file.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "pathname"
|
||||
|
||||
module Opencode
|
||||
# One file living inside an Opencode::Sandbox.
|
||||
#
|
||||
# Carries the safety predicate inline (#safe?) so the orchestrator
|
||||
# doesn't have to know what "safe" means — symlink, realpath inside
|
||||
# the sandbox, size cap. Carries the default identity conversion to
|
||||
# Artifact (#as_artifact) so non-transform code can attach a sandbox
|
||||
# file as-is without re-implementing the marcel + StringIO ceremony.
|
||||
#
|
||||
# mtime-cutoff freshness lives on Opencode::Sandbox#files(after:),
|
||||
# not here — the file doesn't know which turn opened "after." That's
|
||||
# a property of the scan, not a property of the file.
|
||||
class SandboxFile
|
||||
attr_reader :path, :sandbox_prefix
|
||||
|
||||
def initialize(path:, sandbox_prefix:, max_bytes:)
|
||||
@path = path
|
||||
@sandbox_prefix = sandbox_prefix
|
||||
@max_bytes = max_bytes
|
||||
end
|
||||
|
||||
def basename
|
||||
File.basename(path)
|
||||
end
|
||||
|
||||
def size
|
||||
File.size(path)
|
||||
end
|
||||
|
||||
def mtime
|
||||
File.mtime(path)
|
||||
end
|
||||
|
||||
def content
|
||||
File.read(path)
|
||||
end
|
||||
|
||||
def content_type
|
||||
Marcel::MimeType.for(name: basename)
|
||||
end
|
||||
|
||||
# Defense-in-depth on individual file paths the scan yielded:
|
||||
#
|
||||
# - Reject symlinks (no follow-the-link escape).
|
||||
# - The resolved realpath of the path must lie inside the sandbox
|
||||
# with a separator-terminated prefix so /sandbox-1 doesn't false-
|
||||
# positive on /sandbox-10/foo.
|
||||
# - Reject anything over the size cap (default
|
||||
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
|
||||
#
|
||||
# The Sandbox scan filters non-files (directories, FIFOs) before
|
||||
# yielding, so we don't re-check #file? here.
|
||||
def safe?
|
||||
return false if File.symlink?(path)
|
||||
return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix)
|
||||
return false if size > @max_bytes
|
||||
|
||||
true
|
||||
rescue Errno::ENOENT
|
||||
# Concurrent deletion between scan-yield and safety-check — treat
|
||||
# as unsafe so the orchestrator skips rather than crashing.
|
||||
false
|
||||
end
|
||||
|
||||
# Identity conversion: this sandbox file → an Artifact carrying the
|
||||
# file's own bytes. Used by the substrate's default (non-transform)
|
||||
# path, where the agent writes document bytes directly to the
|
||||
# sandbox and the host serves them back unchanged.
|
||||
def as_artifact
|
||||
Artifact.new(
|
||||
filename: basename,
|
||||
content: content,
|
||||
content_type: content_type
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user