14 Commits

Author SHA1 Message Date
9937e45f5e Finalize alpha9 release safeguards 2026-07-20 20:51:01 -07:00
427bd47648 Prepare repaired alpha9 release 2026-07-20 20:04:59 -07:00
b02bcc74ef Harden alpha8 publication safety 2026-07-20 14:26:22 -07:00
a9add2a7c1 Prepare opencode-rails alpha8 2026-07-19 22:21:41 -07:00
61beb17f55 Upgrade checkout action to v7 2026-07-19 18:15:10 -07:00
8b78a558e1 Harden workflow action pin discovery 2026-07-19 17:29:10 -07:00
4d975d99b3 Pin CI actions and test Ruby 4 2026-07-19 16:23:20 -07:00
ajay@krishnan.ca
b27d39edf4 Merge pull request 'Run trusted gem releases only on GitHub' (#1) from fix/github-only-release-runner-guard-20260719 into main 2026-07-19 12:37:48 -07:00
75e4435d9d Test GitHub-only gem release boundary 2026-07-19 06:50:51 -07:00
6148fc67da Run gem releases only on GitHub 2026-07-19 04:30:50 -07:00
Ajay Krishnan
deeccb119e Merge pull request #3 from ajaynomics/docs/readme-executable-quickstart
docs: make alpha7 Rails quickstart executable
2026-07-18 15:31:57 -07:00
fe953020d5 docs: make Rails quickstart match Turn API 2026-07-18 15:29:57 -07:00
Ajay Krishnan
2a391ccad1 Merge pull request #2 from ajaynomics/fix/turn-subscribe-before-prompt-alpha7
Submit Rails turns after SSE readiness
2026-07-18 14:39:25 -07:00
7744fe211a Submit Rails turns after SSE readiness 2026-07-18 14:37:24 -07:00
25 changed files with 1268 additions and 140 deletions

View File

@@ -6,18 +6,76 @@ on:
- "v*"
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Verify tag matches gem version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: >-
ruby -Ilib -ropencode/rails_version -e
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
unless Opencode::RAILS_VERSION == expected'
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-rails.gemspec
- name: Verify published client and installed gems
run: |
client_version="$(bundle exec ruby -ropencode/version -e 'print Opencode::VERSION')"
rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version -e 'print Opencode::RAILS_VERSION').gem"
registry_dir="${RUNNER_TEMP}/opencode-ruby-registry-${{ matrix.ruby }}"
mkdir -p "$registry_dir"
(cd "$registry_dir" && gem fetch opencode-ruby --version "$client_version" --prerelease --clear-sources --source https://rubygems.org)
client_gem="$registry_dir/opencode-ruby-${client_version}.gem"
export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}"
mkdir -p "$GEM_HOME"
gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org
gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org
ruby -ropencode-rails -e '
root = File.realpath(ENV.fetch("GEM_HOME"))
%w[opencode-ruby opencode-rails].each do |name|
path = File.realpath(Gem.loaded_specs.fetch(name).full_gem_path)
abort "#{name} loaded outside isolated GEM_HOME: #{path}" unless path.start_with?("#{root}/")
end
puts Opencode::RAILS_VERSION
'
push:
needs: verify
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: release
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
- uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ruby
ruby-version: "4.0"
bundler-cache: true
- uses: rubygems/release-gem@v1
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -12,12 +12,12 @@ jobs:
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4"]
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true

View File

@@ -1,13 +1,77 @@
# Changelog
## 0.0.1.alpha9 - 2026-07-20
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
hardened SSE framing parser while retaining the alpha7 subscribe-before-
prompt and at-most-once reconnect contract.
### Fixed
- Keep transform destination filenames out of the agent-authored identity
attachment path and require transforms to verify trust explicitly.
- Anchor uploads to an opened sandbox directory across path swaps, replace
destination symlinks without following them, and validate bounded reads from
the opened sandbox file. Upload copying now requires a traversable
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
- Reject hard-linked sandbox artifacts and open files with nonblocking,
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
closed without hanging artifact collection.
- Make the reference integration deny every OpenCode tool by default and
require project-local OpenCode configuration to be disabled at server
startup. Hosts must add their own OS or container boundary before allowing
filesystem tools.
- Load every runtime standard library explicitly and align the shipped
permissions, observer, Turn, prompt, and instrumentation examples with the
actual APIs.
### Changed
- Gate release on tests plus exact local package installation across Ruby 3.2,
3.3, 3.4, and 4.0 in a read-only verification job.
- Pin every third-party CI and release action to an exact reviewed commit and
use Ruby 4.0 for release builds.
- Fail the trusted-publishing job before release when the pushed tag does not
match `Opencode::RAILS_VERSION`.
- Refuse to publish Rails until the RubyGems client package exists and loads
with the locally built Rails package in an isolated gem repository.
## 0.0.1.alpha8 - 2026-07-20
### Unpublished
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
tuple.
## 0.0.1.alpha7 - 2026-07-18
### Fixed
- Make `Opencode::Turn` submit `prompt_async` through the transport's
at-most-once `on_subscribed` callback, after `server.connected` proves the
SSE listener is ready. Reconnects reopen only SSE and never replay the user
prompt.
- Fail the turn directly when subscription setup or the prompt POST fails
before a turn is confirmed started. The recovery path no longer risks
finalizing stale exchange text after a pre-turn failure.
- Add a gem-level behavioral regression for the cross-gem ordering contract,
including reconnect and ambiguous prompt timeout cases.
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha7`.
## 0.0.1.alpha6 - 2026-07-18
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. Rails turns
now establish and validate the OpenCode SSE subscription before submitting
`prompt_async`, and automatic SSE reconnects reopen only the subscription
without replaying the user prompt.
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. This exposed
subscribe-before-prompt through `Client#stream`, but the lower-level
`Opencode::Turn` path still sent before `stream_events`; that orchestration
gap is fixed in alpha7.
## 0.0.1.alpha5 - 2026-07-15

View File

@@ -2,6 +2,6 @@ source "https://rubygems.org"
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "b16292723e6385064064fdd61698c9618f6cb156"
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gemspec

137
README.md
View File

@@ -17,25 +17,55 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
## Install
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
```ruby
# Gemfile
gem "opencode-ruby" # wire client + Reply state machine
gem "opencode-rails" # AR-coupled session/turn/artifact stack
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha9"
```
Until publication is verified, validate this candidate checkout against the
exact `opencode-ruby` source it was tested with:
```ruby
# Gemfile
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails",
path: "../opencode-rails"
```
```bash
bundle install
```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha6
uses a subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again.
`UploadedFilesPrompt` anchors upload writes through `/proc/self/fd` or
`/dev/fd` to prevent sandbox-root path swaps. Hosts using upload copying must
provide one of those traversable descriptor filesystems; unsupported platforms
fail closed before writing.
Releases use RubyGems trusted publishing. Register `release.yml` as the gem's
trusted publisher (using the `release` environment); after that, a `v*` tag
publishes without a long-lived RubyGems API key.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
retains the subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again, while hardening SSE framing.
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
published on RubyGems. Because the gem has no RubyGems entry yet, create a
pending trusted publisher for gem `opencode-rails`, repository owner
`ajaynomics`, repository `opencode-rails`, workflow `release.yml`, and
environment `release`. RubyGems converts it to a normal trusted publisher after
the first successful push. The workflow also refuses to publish until the
RubyGems `opencode-ruby` alpha9 package exists and installs and loads with the
locally built Rails package. Until the registry result is verified, pushing a
`v*` tag does not guarantee publication. Trusted publishing does not require a
long-lived RubyGems API key.
## Quickstart
@@ -49,13 +79,35 @@ Opencode::ErrorReporter.adapter = ->(error, **opts) {
}
```
```ruby
# app/services/noop_reply_observer.rb
#
# Turn requires an observer factory even when the app does not need live
# partial rendering. For a streaming UI, replace this with an observer that
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
class NoopReplyObserver
include Opencode::ReplyObserver
def watch(reply)
reply.add_observer(self)
self
end
end
```
```ruby
# app/jobs/generate_response_job.rb
class GenerateResponseJob < ApplicationJob
def perform(assistant_message)
def perform(assistant_message, user_message)
conversation = assistant_message.conversation
user_message = conversation.messages.where(role: :user).last
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"])
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: working_directory
)
session = Opencode::Session.new(
conversation,
@@ -68,9 +120,15 @@ class GenerateResponseJob < ApplicationJob
subject: conversation,
query_text: user_message.content,
client: client,
session: session,
session_for: session,
observer_factory: ->(_message) { NoopReplyObserver.new },
system_context: ->(record) { "You are assisting with #{record.title}." },
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) {
# result.status #=> :completed | :error | :cancelled
# result.status #=> :completed | :cancelled | :error | :failed
# result.message #=> the AR row (reloaded)
# result.duration_ms
}
@@ -79,15 +137,41 @@ class GenerateResponseJob < ApplicationJob
private
def permission_rules_for(conversation)
def permission_rules_for(_conversation)
[
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }
{ permission: "*", pattern: "*", action: "deny" }
]
end
end
```
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`.
Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
project configuration, plugins, and instructions while opening a directory,
before session permissions exist; the wildcard deny rule cannot constrain that
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
mounted at the same absolute path in Rails and OpenCode. Treat the server's
global configuration, plugins, and instructions as privileged administrator
code and verify the setting against the deployed OpenCode version.
This quickstart intentionally grants no filesystem tools: OpenCode permissions
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
process also has an independent container or operating-system boundary.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions
with a client scoped to the new directory; an existing session ID does not
prove that the new policy was applied.
The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
`#error!(content)`. A non-no-op observer may impose additional record methods
for its own live snapshots.
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
constructor is intentionally explicit and may change before 1.0, so keep this
wiring in one host service/job and keep the gem source pinned exactly.
## What you get
@@ -110,17 +194,18 @@ Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Trace
## Instrumentation + error reporting
The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above).
The wire client emits `opencode.request` and artifact extraction emits
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
Turn events flow through the injected tracer; with the Quickstart's
`assistant.` prefix they include:
Events emitted (non-exhaustive):
- `assistant.response.started`, `assistant.turn.finished`
- `assistant.stream.completed`, `assistant.stream.interrupted`
- `assistant.session.created`, `assistant.session.recreated_with_resend`
- `assistant.response.upstream_error`
- `opencode.turn.started`, `opencode.turn.finished`
- `opencode.stream.completed`, `opencode.stream.interrupted`
- `opencode.session.created`, `opencode.session.recreated`
- `opencode.apply_patch.artifacts_dropped`
- `opencode.response.upstream_error`
Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter.
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
Instrumentation and error reporting are no-ops until the host wires adapters.
## Position

View File

@@ -27,8 +27,10 @@
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
# 4. Permissions builder: per-product session permission rules.
#
# Tested patterns. Every line below has been exercised in production.
# Copy what you need; adapt to your domain.
# Contract-tested reference. Copy what you need and adapt it to your domain.
# The OpenCode server process must run with
# OPENCODE_DISABLE_PROJECT_CONFIG=1; session permissions are too late to
# constrain project configuration, plugins, or instructions at bootstrap.
# ----------------------------------------------------------------------
# 1. config/initializers/opencode.rb
@@ -39,10 +41,8 @@
# events to its observability stack.
Rails.application.config.to_prepare do
# opencode-ruby: every HTTP request, SSE event lifecycle, recovery
# path flows through this adapter as an ActiveSupport::Notifications
# event. Subscribers in this app pick it up via
# `ActiveSupport::Notifications.subscribe("opencode.*", ...)`.
# opencode-ruby HTTP requests flow through this adapter as
# `opencode.request` ActiveSupport::Notifications events.
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
ActiveSupport::Notifications.instrument(name, payload, &block)
}
@@ -70,18 +70,26 @@ class GenerateResponseJob < ApplicationJob
# SolidQueue concurrency_key — only one turn per conversation in
# flight at a time. Without this, a user sending two messages back-
# to-back can race two turns through the same OpenCode session.
limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" }
limits_concurrency to: 1, key: ->(message, _user_message) { "GenerateResponseJob/#{message.conversation_id}" }
def perform(assistant_message)
def perform(assistant_message, user_message)
return if assistant_message.terminal? # idempotent
conversation = assistant_message.conversation
user_message = conversation.messages.where(role: :user).order(:created_at).last
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: working_directory
)
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
# OpenCode session if conversation.opencode_session_id is blank;
# returns the existing id otherwise.
# Session applies permissions only when it creates a session.
# Recreate persisted sessions before deploying a directory or policy change.
session = Opencode::Session.new(
conversation,
permissions_for: ->(record) { permission_rules_for(record) },
@@ -96,11 +104,13 @@ class GenerateResponseJob < ApplicationJob
subject: conversation,
query_text: user_message.content,
client: client,
session_for: ->(*) { session },
session_for: session,
observer_factory: ->(message) { ReplyStream.new(message: message) },
system_context: build_system_context(conversation),
agent_name: "default",
tracer: Opencode::Tracer.new(prefix: "opencode."),
system_context: ->(record) { build_system_context(record) },
agent_name: ->(_record) { "build" },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) {
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
}
@@ -113,23 +123,21 @@ class GenerateResponseJob < ApplicationJob
private
def permission_rules_for(conversation)
def permission_rules_for(_conversation)
# Per-product permissions. The shape mirrors what
# opencode-ruby's Client#create_session expects in `permissions:`.
[
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" },
{ type: "edit", action: "deny", path: "*" } # default deny outside sandbox
{ permission: "*", pattern: "*", action: "deny" }
]
end
def build_system_context(conversation)
# System prompt context the agent gets. Your app probably already
# has helpers for this; the gem doesn't impose a shape.
{
user_name: conversation.user.name,
conversation_id: conversation.id,
sandbox_path: "/sandbox/#{conversation.id}"
}
<<~CONTEXT
User: #{conversation.user.name}
Conversation: #{conversation.id}
CONTEXT
end
end
@@ -138,7 +146,7 @@ end
# ----------------------------------------------------------------------
#
# An Opencode::ReplyObserver implementation that bridges the gem's
# state-machine callbacks (part_appended, part_updated, finalized) to
# state-machine callbacks (part_added, part_changed, part_finalized) to
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
# the rendering.
#
@@ -147,51 +155,47 @@ end
# build_system_context above.
class ReplyStream
include Opencode::ReplyObserver
def initialize(message:)
@message = message
@parts_dom_id = "parts_message_#{message.id}"
end
# Called every time a new part shows up in the reply.
def on_part_appended(part)
def watch(reply)
reply.add_observer(self)
self
end
def part_added(part:, index:)
Turbo::StreamsChannel.broadcast_append_to(
@message.conversation,
target: @parts_dom_id,
partial: "messages/part",
locals: { part: part, message: @message }
locals: { part: part, index: index, message: @message }
)
end
# Called when an existing part's content grows (text/reasoning
# deltas, tool state changes).
def on_part_updated(part, _index)
def part_changed(part:, index:, delta:)
broadcast_update(part, index)
end
def part_finalized(part:, index:)
broadcast_update(part, index)
end
def tool_progressed(part:, index:, status:, raw:)
broadcast_update(part, index)
end
private
def broadcast_update(part, index)
Turbo::StreamsChannel.broadcast_update_to(
@message.conversation,
target: "part_#{part['id']}_message_#{@message.id}",
target: "part_#{index}_message_#{@message.id}",
partial: "messages/part",
locals: { part: part, message: @message }
)
end
# Called when the turn finalizes. Use this to swap "Thinking…"
# placeholders, update message status indicators, etc.
def on_finalized(reply_result)
Turbo::StreamsChannel.broadcast_update_to(
@message.conversation,
target: "message_#{@message.id}_status",
partial: "messages/status",
locals: { message: @message, result: reply_result }
)
end
# Optional: called when the gem catches an exception mid-stream and
# has done its own recovery (recreated session, retried, etc.). Use
# this for a brief transient banner in the UI.
def on_error(message, severity:)
Turbo::StreamsChannel.broadcast_replace_to(
@message.conversation,
target: "message_#{@message.id}_status",
html: %(<div class="banner banner--warn">#{message}</div>)
locals: { part: part, index: index, message: @message }
)
end
end
@@ -201,9 +205,7 @@ end
# - Idempotent session create/resolve with row-level locking
# - SSE stream consumption + reconnection on transport hiccups
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
# - Mid-stream parts_json snapshotting via update_columns (bypasses
# after_save callbacks; your row-level Turbo broadcasts fire on
# YOUR cadence, not every SSE event)
# - ReplyObserver callbacks for host-owned live broadcasts or snapshots
# - CAS-safe finalize: message reloaded under row lock, transitions
# :pending -> :completed only if a concurrent cancel hasn't already
# moved it out of :pending.

View File

@@ -9,6 +9,12 @@
# rename work.
require "opencode-ruby"
require "fileutils"
require "marcel"
require "pathname"
require "set"
require "stringio"
require "tempfile"
require "active_support/core_ext/object/blank" # blank?, present?, presence
require "active_support/core_ext/object/try"

View File

@@ -80,6 +80,10 @@ module Opencode
if (transform = transforms.find { |t| t.applies_to?(file) })
attached += 1 if apply_transform(transform, file)
elsif transform_owned_filenames.include?(file.basename)
# Destination names belong exclusively to their host transform.
# Agent-authored bytes must never fall through as trusted output.
next
elsif default_attach == :all
# Default identity path: every safe sandbox file that no
# transform claims attaches as-is. Callers that want the

View File

@@ -11,5 +11,5 @@
# We can't reuse the same constant from a second gem, so we use a
# distinct, non-namespaced constant.
module Opencode
RAILS_VERSION = "0.0.1.alpha6"
RAILS_VERSION = "0.0.1.alpha9"
end

View File

@@ -15,6 +15,8 @@ module Opencode
# 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
UnsafeFileError = Class.new(Opencode::Error)
attr_reader :path, :sandbox_prefix
def initialize(path:, sandbox_prefix:, max_bytes:)
@@ -36,7 +38,13 @@ module Opencode
end
def content
File.read(path)
with_safe_file do |file|
content = file.read(@max_bytes + 1) || "".b
if content.bytesize > @max_bytes
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
end
content
end
end
def content_type
@@ -52,17 +60,11 @@ module Opencode
# - 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.
# Revalidates the opened file descriptor so a path swap between the
# sandbox scan and the read cannot redirect content outside the sandbox.
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.
with_safe_file { true }
rescue UnsafeFileError
false
end
@@ -77,5 +79,46 @@ module Opencode
content_type: content_type
)
end
private
def with_safe_file
before = File.lstat(path)
unless before.file? && !before.symlink? && before.nlink == 1
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
end
resolved = Pathname.new(path).realpath.to_s
unless resolved.start_with?(sandbox_prefix)
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
end
flags = safe_open_flags
File.open(path, flags, encoding: Encoding::BINARY) do |file|
opened = file.stat
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
end
if opened.size > @max_bytes
raise UnsafeFileError, "Sandbox file exceeds size limit: #{basename}"
end
yield file
end
rescue SystemCallError => e
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
end
def safe_open_flags
required = %i[NONBLOCK NOFOLLOW]
missing = required.reject { |name| File.const_defined?(name) }
unless missing.empty?
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
end
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
flags |= File::BINARY if File.const_defined?(:BINARY)
flags
end
end
end

View File

@@ -31,7 +31,8 @@ module Opencode
# trusted?(attachment) — true if the attachment was produced by
# this transform (used by Impostor.for and
# by view code that decides inline-render
# vs download). Default: filename match.
# vs download). Default: false; subclasses
# must verify host-authored metadata.
# purge_impostors? — if true, before attaching the substrate
# deletes any existing attachment whose
# filename matches destination_filename
@@ -60,8 +61,8 @@ module Opencode
raise NotImplementedError, "#{self.class.name} must implement #render"
end
def trusted?(attachment)
attachment.filename.to_s == destination_filename
def trusted?(_attachment)
false
end
def purge_impostors?

View File

@@ -213,12 +213,6 @@ module Opencode
emit_session_created_if_new
validate_session!(session_id)
@client.send_message_async(
session_id, @query_text,
agent: @agent_name.call(@subject),
system: @system_context.call(@subject)
)
stream_result = stream_response(session_id)
exchange = fetch_current_exchange(session_id)
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
@@ -260,6 +254,22 @@ module Opencode
last_activity_touch_at = stream_started_at
first_token_at = nil
event_count = 0
prompt_attempted = false
prompt_succeeded = false
on_subscribed = lambda do
# stream_events guarantees at-most-once invocation, but keep this
# guard here as a second line of defense because an ambiguous prompt
# response must never become a duplicate model turn.
next false if prompt_attempted
prompt_attempted = true
@client.send_message_async(
session_id, @query_text,
agent: @agent_name.call(@subject),
system: @system_context.call(@subject)
)
prompt_succeeded = true
end
begin
release_active_record_connections
@@ -293,7 +303,8 @@ module Opencode
@client.stream_events(
session_id: session_id,
reply: reply,
on_activity_tick: activity_tick
on_activity_tick: activity_tick,
on_subscribed: on_subscribed
) do |event|
event_count += 1
reply.apply(event)
@@ -308,6 +319,13 @@ module Opencode
rescue Opencode::SessionNotFoundError
raise
rescue StandardError => e
# Subscription rejection or prompt transport failure happened before
# a turn was confirmed started. Recovering from the pre-turn exchange
# could finalize stale text, and retrying an ambiguous POST could
# duplicate spend, so surface the original failure to the outer error
# path without reconnect/recovery.
raise unless prompt_succeeded
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, error_class: e.class.name })
emit("stream.interrupted",

View File

@@ -24,8 +24,10 @@ module Opencode
# Side effect, unchanged from the concern: file bytes are copied from
# ActiveStorage into the per-user OpenCode sandbox directory so the
# agent can read them with the `read` tool. The copy is path-escape
# guarded (the cleanpath of the destination must start with the
# sandbox dir prefix, no symlink trickery).
# guarded: generated names must be basenames, and all writes stay anchored
# to an opened directory handle even if the sandbox path is replaced.
# A temporary file is renamed into place so destination symlinks are
# replaced rather than followed. Retried jobs can refresh existing copies.
class UploadedFilesPrompt
attr_reader :text, :sandbox_file_names
@@ -52,24 +54,77 @@ module Opencode
[
raw,
"",
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly before responding:",
*file_instructions
].join("\n").strip
end
def copy_to_sandbox(file)
FileUtils.mkdir_p(@sandbox_path)
sandbox_path = Pathname.new(@sandbox_path).expand_path
FileUtils.mkdir_p(sandbox_path)
sandbox_stat = File.lstat(sandbox_path)
unless sandbox_stat.directory? && !sandbox_stat.symlink?
raise ArgumentError, "Sandbox root must be a directory, not a symlink: #{sandbox_path}"
end
sandbox_name = @sandbox_name_for.call(file)
dest = File.join(@sandbox_path, sandbox_name)
resolved = Pathname.new(dest).cleanpath.to_s
unless resolved.start_with?(@sandbox_path)
sandbox_name = @sandbox_name_for.call(file).to_s
unless sandbox_name == File.basename(sandbox_name) && !%w[. ..].include?(sandbox_name)
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
end
File.open(dest, "wb") { |f| f.write(file.download) }
Placement.new(sandbox_name, dest)
File.open(sandbox_path, File::RDONLY) do |directory|
opened_stat = directory.stat
unless opened_stat.directory? && same_file?(sandbox_stat, opened_stat)
raise ArgumentError, "Sandbox root changed while opening: #{sandbox_path}"
end
directory_path = directory_handle_path(directory)
dest = File.join(directory_path, sandbox_name)
mode = destination_mode(dest)
Tempfile.create([ ".opencode-upload-", ".tmp" ], directory_path) do |temp|
temp.binmode
temp.write(file.download)
temp.flush
temp.chmod(mode)
File.rename(temp.path, dest)
unless same_file_at_path?(sandbox_path, opened_stat)
File.unlink(dest)
raise ArgumentError, "Sandbox root changed during upload: #{sandbox_path}"
end
end
end
Placement.new(sandbox_name, sandbox_path.join(sandbox_name).to_s)
end
def directory_handle_path(directory)
stat = directory.stat
[ "/proc/self/fd/#{directory.fileno}", "/dev/fd/#{directory.fileno}" ].find do |path|
same_file?(File.stat(path), stat)
rescue Errno::ENOENT
false
end || raise(ArgumentError, "Platform cannot anchor writes to the opened sandbox directory")
end
def destination_mode(path)
stat = File.lstat(path)
return stat.mode & 0o777 if stat.file? && !stat.symlink?
0o666 & ~File.umask
rescue Errno::ENOENT
0o666 & ~File.umask
end
def same_file_at_path?(path, expected)
current = File.lstat(path)
current.directory? == expected.directory? && !current.symlink? && same_file?(current, expected)
rescue Errno::ENOENT
false
end
def same_file?(left, right)
left.dev == right.dev && left.ino == right.ino
end
# Tiny value pair returned by copy_to_sandbox: the canonical filename

View File

@@ -37,7 +37,8 @@ Gem::Specification.new do |spec|
# this gem builds on. During alpha both gems evolve in lockstep — we pin
# exactly (= not ~>) so that consumers always pick the version this gem
# was tested against.
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha6"
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
spec.add_runtime_dependency "marcel", "~> 1.0"
# Rails sub-libraries used at runtime. Depending on these individually
# (instead of the `rails` umbrella) avoids forcing host apps to load

67
test/example_test.rb Normal file
View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require "test_helper"
require "ripper"
class ExampleTest < Minitest::Test
PATH = File.expand_path("../examples/rails_integration.rb", __dir__)
SOURCE = File.read(PATH)
def test_example_parses
assert Ripper.sexp(SOURCE)
end
def test_example_uses_turn_collaborator_contracts
assert_includes SOURCE, "def perform(assistant_message, user_message)"
assert_includes SOURCE, "session_for: session"
assert_includes SOURCE, "system_context: ->(record)"
assert_includes SOURCE, "agent_name: ->(_record)"
assert_includes SOURCE, '{ "build" }'
assert_includes SOURCE, "def watch(reply)"
assert_includes SOURCE, "include Opencode::ReplyObserver"
assert_includes SOURCE, 'ActiveSupport::Notifications.instrument("assistant.#{name}", payload)'
refute_includes SOURCE, 'Opencode::Tracer.new(prefix: "opencode.")'
refute_includes SOURCE, ".order(:created_at).last"
end
def test_example_uses_reply_indexes_for_dom_identity
assert_includes SOURCE, 'target: "part_#{index}_message_#{@message.id}"'
assert_includes SOURCE, "locals: { part: part, index: index, message: @message }"
refute_includes SOURCE, "part['id']"
end
def test_example_does_not_claim_unimplemented_mid_stream_persistence
refute_includes SOURCE, "Mid-stream parts_json snapshotting"
refute_includes SOURCE, "update_columns"
end
def test_example_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, SOURCE)
assert_includes SOURCE, 'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes SOURCE, "directory: working_directory"
assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes SOURCE, 'action: "allow"'
assert_includes SOURCE, "OPENCODE_DISABLE_PROJECT_CONFIG=1"
refute_includes SOURCE, "ConversationSandbox"
assert_includes SOURCE, "permissions only when it creates a session"
assert_match(/recreate persisted sessions/i, SOURCE)
refute_includes SOURCE, "Sandbox:"
end
def test_example_permission_order_denies_every_tool
body = SOURCE[/def permission_rules_for\(_conversation\)\n(?<body>.*?)^ end/m, :body]
rules = eval(body, binding, PATH)
action_for = lambda do |permission, pattern|
rules.reverse.find do |rule|
(rule.fetch(:permission) == "*" || rule.fetch(:permission) == permission) &&
(rule.fetch(:pattern) == "*" || rule.fetch(:pattern) == pattern)
end.fetch(:action)
end
assert_equal "deny", action_for.call("read", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("edit", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("external_directory", "/outside/*")
assert_equal "deny", action_for.call("grep", "secret")
assert_equal "deny", action_for.call("lsp", "*")
end
end

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true
require "test_helper"
require "open3"
# Smoke test: every constant the gem promises is defined and points at
# the right kind of object. If require "opencode-rails" loads cleanly,
@@ -46,8 +47,9 @@ class Opencode::LoadingTest < Minitest::Test
def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first
assert_match GEM_SOURCE_PATTERN.call("opencode-rails", "session"), location,
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
assert_equal expected, File.expand_path(location),
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
end
def test_client_constant_points_at_opencode_ruby
@@ -71,4 +73,24 @@ class Opencode::LoadingTest < Minitest::Test
refute Opencode.const_defined?(:Rails),
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
end
def test_umbrella_require_loads_runtime_standard_libraries
root = File.expand_path("../..", __dir__)
script = <<~'RUBY'
require "opencode-rails"
abort "StringIO missing" unless defined?(StringIO)
abort "Marcel missing" unless defined?(Marcel::MimeType)
abort "FileUtils missing" unless defined?(FileUtils)
abort "Tempfile missing" unless defined?(Tempfile)
RUBY
_stdout, stderr, status = Open3.capture3(
Gem.ruby,
"-I#{File.join(root, "lib")}",
"-e",
script
)
assert status.success?, stderr
end
end

View File

@@ -7,6 +7,23 @@ require "test_helper"
# coverage — including ActiveStorage attachment, Transform application,
# error reporting via Opencode::ErrorReporter — lives in the host app.
class Opencode::MessageArtifactsTest < Minitest::Test
Message = Struct.new(:id, keyword_init: true)
Sandbox = Struct.new(:entries, keyword_init: true) do
def exists? = true
def files(after: nil) = entries
end
SandboxEntry = Struct.new(:basename, :attached, keyword_init: true) do
def as_artifact
self.attached = true
raise "destination must not use the identity attachment path"
end
end
class Transform < Opencode::Transform
def source_filename = "source.json"
def destination_filename = "rendered.html"
end
def test_initialize_takes_message_feature_and_optional_transforms
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
@@ -23,4 +40,17 @@ class Opencode::MessageArtifactsTest < Minitest::Test
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
"MessageArtifacts's only public verb is #attach_from"
end
def test_agent_authored_transform_destination_never_uses_default_attachment
entry = SandboxEntry.new(basename: "rendered.html", attached: false)
artifacts = Opencode::MessageArtifacts.new(
message: Message.new(id: 1),
feature: "test",
transforms: [ Transform.new ]
)
artifacts.attach_from(sandbox: Sandbox.new(entries: [ entry ]))
refute entry.attached
end
end

View File

@@ -2,6 +2,7 @@
require "test_helper"
require "fileutils"
require "socket"
require "tmpdir"
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
@@ -11,6 +12,7 @@ require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
class Opencode::SandboxFileTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
@outside_dir = Dir.mktmpdir("opencode-rails-sandbox-file-outside-")
@path = File.join(@tmpdir, "notes.md")
File.write(@path, "# hello\nworld\n")
# SandboxFile uses `start_with?` against this prefix to detect path
@@ -21,6 +23,7 @@ class Opencode::SandboxFileTest < Minitest::Test
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
FileUtils.remove_entry(@outside_dir) if @outside_dir && File.exist?(@outside_dir)
end
def test_basic_readers
@@ -59,4 +62,147 @@ class Opencode::SandboxFileTest < Minitest::Test
assert_equal "notes.md", artifact.filename
assert_equal "# hello\nworld\n", artifact.content
end
def test_content_rejects_a_file_replaced_by_an_outside_symlink
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
assert file.safe?
File.unlink(@path)
File.symlink(outside, @path)
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
def test_safe_rejects_a_hard_link
File.unlink(@path)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
File.link(outside, @path)
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
refute file.safe?
end
def test_content_rejects_a_hard_link_added_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside_link = File.join(@outside_dir, "linked-notes.md")
real_open = File.method(:open)
link_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.link(path, outside_link)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, link_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
)
real_open = File.method(:open)
grow_on_read = lambda do |path, *args, **kwargs, &block|
real_open.call(path, *args, **kwargs) do |opened|
proxy = Object.new
proxy.define_singleton_method(:stat) { opened.stat }
proxy.define_singleton_method(:read) do |length = nil|
real_open.call(path, "ab") { |writer| writer.write("x" * 100) }
opened.read(length)
end
block.call(proxy)
end
end
File.stub(:open, grow_on_read) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_a_fifo_swapped_in_immediately_before_open_without_blocking
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.mkfifo(path)
assert_kind_of Integer, mode
assert_operator mode & File::NONBLOCK, :>, 0
assert_operator mode & File::NOFOLLOW, :>, 0
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_a_symlink_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.symlink(outside, path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_safe_rejects_a_socket_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
socket = nil
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
socket = UNIXServer.new(path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
refute file.safe?
end
ensure
socket&.close
end
def test_safe_fails_closed_when_secure_open_flags_are_unavailable
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_const_defined = File.method(:const_defined?)
const_defined = lambda do |name, inherit = true|
name == :NOFOLLOW ? false : real_const_defined.call(name, inherit)
end
File.stub(:const_defined?, const_defined) do
refute file.safe?
end
end
def test_content_returns_binary_empty_string_for_a_zero_byte_file
File.write(@path, "")
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
)
assert_equal "".b, file.content
end
end

View File

@@ -29,7 +29,7 @@ class Opencode::TransformTest < Minitest::Test
end
# A trivial concrete subclass exercises the defaults that DO exist
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
# (#applies_to? and #owned_filenames delegate to the
# two abstract filename methods).
class FakeTransform < Opencode::Transform
def source_filename = "agent-output.json"
@@ -48,13 +48,11 @@ class Opencode::TransformTest < Minitest::Test
refute transform.applies_to?(other)
end
def test_trusted_matches_destination_filename_by_default
def test_trusted_fails_closed_by_default
transform = FakeTransform.new
trusted = Attachment.new(filename: "rendered.html")
untrusted = Attachment.new(filename: "agent-output.json")
destination = Attachment.new(filename: "rendered.html")
assert transform.trusted?(trusted)
refute transform.trusted?(untrusted)
refute transform.trusted?(destination)
end
def test_owned_filenames_is_source_and_destination

View File

@@ -3,11 +3,107 @@
require "test_helper"
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
# Result value object. Behavioral coverage (the full send -> stream ->
# recover -> finalize loop) lives in the host application — Turn needs
# an Opencode::Client, an AR Message, a subject record, etc., which are
# all integration-level concerns.
# Result value object. Most ActiveRecord behavior lives in host applications,
# but the subscribe-before-prompt ordering is a cross-gem transport contract
# and belongs here so a host cannot silently bypass opencode-ruby's guarantee.
class Opencode::TurnTest < Minitest::Test
SESSION_ID = "ses_turn_test"
class FakeMessage
attr_reader :id, :finalized, :error_content
attr_accessor :cost, :input_tokens, :output_tokens, :tool_calls_json
def initialize
@id = 12
end
def reload = self
def cancelled? = false
def finalize!(**attrs)
@finalized = attrs
@cost = attrs[:cost]
@input_tokens = attrs[:input_tokens]
@output_tokens = attrs[:output_tokens]
@tool_calls_json = attrs[:tool_calls_json]
true
end
def error!(content)
@error_content = content
end
end
FakeSubject = Struct.new(:id, :opencode_session_id, keyword_init: true)
class FakeSession
def ensure!(_client) = SESSION_ID
def just_created? = false
end
class FakeObserver
def watch(_reply); end
end
class OrderedClient
attr_reader :order, :prompt_count, :message_reads
def initialize(prompt_error: nil)
@order = []
@prompt_count = 0
@message_reads = 0
@prompt_error = prompt_error
end
def get_messages(_session_id)
@message_reads += 1
@order << (@prompt_count.zero? ? :messages_before : :messages_after)
return [] if @prompt_count.zero?
[
{ info: { role: "user" }, parts: [ { type: "text", text: "ping" } ] },
{
info: {
role: "assistant", finish: "stop",
time: { created: 1, completed: 2 },
cost: 0.01,
tokens: { input: 2, output: 1 }
},
parts: [ { type: "text", text: "pong" } ]
}
]
end
def send_message_async(session_id, text, agent:, system:)
@prompt_count += 1
@order << :prompt
raise @prompt_error if @prompt_error
raise "wrong prompt" unless session_id == SESSION_ID && text == "ping"
raise "wrong routing" unless agent == "test-agent" && system == "test-system"
{}
end
def stream_events(session_id:, reply:, on_activity_tick:, on_subscribed:)
raise "wrong session" unless session_id == SESSION_ID
raise "missing reply" unless reply.is_a?(Opencode::Reply)
raise "missing activity callback" unless on_activity_tick.respond_to?(:call)
@order << :sse_ready
on_subscribed.call
@order << :sse_reconnected
on_subscribed.call
yield(
type: "message.part.delta",
properties: { sessionID: SESSION_ID, partID: "p1", field: "text", delta: "pong" }
)
yield(
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
)
end
end
REQUIRED_INIT_KEYS = %i[
message subject query_text client session_for observer_factory
system_context agent_name tracer
@@ -59,4 +155,56 @@ class Opencode::TurnTest < Minitest::Test
assert_equal 100, result.input_tokens
assert_equal 50, result.output_tokens
end
def test_turn_subscribes_before_prompt_and_never_reprompts_on_reconnect
client = OrderedClient.new
message = FakeMessage.new
results = []
build_turn(client:, message:, results:).call
assert_equal 1, client.prompt_count
assert_equal(
[ :messages_before, :sse_ready, :prompt, :sse_reconnected, :messages_after ],
client.order
)
assert_equal "pong", message.finalized.fetch(:content)
assert_nil message.error_content
assert results.last.completed?
end
def test_turn_does_not_recover_or_retry_an_ambiguous_prompt_failure
client = OrderedClient.new(prompt_error: Net::ReadTimeout.new("prompt timed out"))
message = FakeMessage.new
results = []
build_turn(client:, message:, results:).call
assert_equal 1, client.prompt_count
assert_equal 1, client.message_reads
assert_nil message.finalized
assert_equal Opencode::Turn::ERROR_FALLBACK_CONTENT, message.error_content
assert results.last.failed?
assert_instance_of Net::ReadTimeout, results.last.error
end
private
def build_turn(client:, message:, results:)
Opencode::Turn.new(
message: message,
subject: FakeSubject.new(id: 34, opencode_session_id: SESSION_ID),
query_text: "ping",
client: client,
session_for: FakeSession.new,
observer_factory: ->(_message) { FakeObserver.new },
system_context: ->(_subject) { "test-system" },
agent_name: ->(_subject) { "test-agent" },
tracer: ->(_name, **_payload) {},
on_turn_finished: ->(result) { results << result },
empty_stream_retry_delay: 0,
final_exchange_timeout: 0,
final_exchange_retry_delay: 0
)
end
end

View File

@@ -17,6 +17,13 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
EmptyFiles = Struct.new(:attached) do
def attached? = attached
end
AttachedFiles = Class.new(Array) do
def attached? = true
end
FakeUpload = Struct.new(:filename, :content_type, :content, keyword_init: true) do
def byte_size = content.bytesize
def download = content
end
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
@@ -52,4 +59,130 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
assert_equal %i[sandbox_file_names text].sort,
Opencode::UploadedFilesPrompt.instance_methods(false).sort
end
def test_copies_an_upload_without_domain_specific_instructions
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
prompt = Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "hello", File.binread(File.join(@tmpdir, "upload.txt"))
assert_includes prompt.text, "Read each file thoroughly before responding:"
refute_includes prompt.text, "legal claims"
refute_includes prompt.text, "reference materials"
end
def test_rejects_a_sibling_prefix_escape
sandbox = File.join(@tmpdir, "foo")
sibling = File.join(@tmpdir, "foobar")
FileUtils.mkdir_p(sibling)
target = File.join(sibling, "target.txt")
File.write(target, "safe")
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
assert_raises(ArgumentError) do
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "../foobar/target.txt" }
)
end
assert_equal "safe", File.read(target)
end
def test_atomically_replaces_a_destination_symlink_without_following_it
outside = File.join(@tmpdir, "outside.txt")
sandbox = File.join(@tmpdir, "sandbox")
FileUtils.mkdir_p(sandbox)
File.write(outside, "safe")
File.symlink(outside, File.join(sandbox, "upload.txt"))
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "safe", File.read(outside)
assert_equal "overwritten", File.read(File.join(sandbox, "upload.txt"))
refute File.symlink?(File.join(sandbox, "upload.txt"))
end
def test_retried_copy_refreshes_an_existing_sandbox_file
sandbox = File.join(@tmpdir, "sandbox")
FileUtils.mkdir_p(sandbox)
File.write(File.join(sandbox, "upload.txt"), "stale")
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal "fresh", File.read(File.join(sandbox, "upload.txt"))
end
def test_rejects_a_sandbox_root_replaced_during_the_copy
sandbox = File.join(@tmpdir, "sandbox")
moved_sandbox = File.join(@tmpdir, "moved-sandbox")
FileUtils.mkdir_p(sandbox)
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "private")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
create = Tempfile.method(:create)
replace_root = lambda do |*args, **kwargs, &block|
File.rename(sandbox, moved_sandbox)
FileUtils.mkdir_p(sandbox)
create.call(*args, **kwargs, &block)
end
Tempfile.stub(:create, replace_root) do
assert_raises(ArgumentError) do
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: sandbox,
sandbox_name_for: ->(_file) { "upload.txt" }
)
end
end
refute File.exist?(File.join(sandbox, "upload.txt"))
refute File.exist?(File.join(moved_sandbox, "upload.txt"))
end
def test_new_upload_uses_the_normal_umask_file_mode
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal 0o666 & ~File.umask, File.stat(File.join(@tmpdir, "upload.txt")).mode & 0o777
end
def test_retried_copy_preserves_an_existing_file_mode
existing = File.join(@tmpdir, "upload.txt")
File.write(existing, "stale")
File.chmod(0o640, existing)
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(_file) { "upload.txt" }
)
assert_equal 0o640, File.stat(existing).mode & 0o777
end
end

82
test/readme_test.rb Normal file
View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
require "test_helper"
require "ripper"
class ReadmeTest < Minitest::Test
README_PATH = File.expand_path("../README.md", __dir__)
GEMFILE_PATH = File.expand_path("../Gemfile", __dir__)
def setup
@readme = File.read(README_PATH)
@quickstart = @readme[/^## Quickstart\n(?<body>.*?)(?=^## )/m, :body]
refute_nil @quickstart, "README must retain a Quickstart section"
end
def test_quickstart_turn_call_documents_every_required_keyword
turn_call = @quickstart[/Opencode::Turn\.new\((?<args>.*?)^\s*\)\.call/m, :args]
refute_nil turn_call, "Quickstart must contain an Opencode::Turn.new(...).call example"
documented = turn_call.scan(/^\s*([a-z_]+):/).flatten.map(&:to_sym)
parameters = Opencode::Turn.instance_method(:initialize).parameters
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
accepted = parameters.filter_map { |kind, name| name if %i[keyreq key].include?(kind) }
assert_empty required - documented,
"Quickstart is missing required Turn keywords: #{(required - documented).join(", ")}"
assert_empty documented - accepted,
"Quickstart uses unsupported Turn keywords: #{(documented - accepted).join(", ")}"
refute_includes documented, :session
end
def test_readme_ruby_fences_parse
ruby_fences = @readme.scan(/```ruby\n(.*?)```/m).flatten
refute_empty ruby_fences
ruby_fences.each_with_index do |source, index|
assert Ripper.sexp(source), "README Ruby fence #{index + 1} has invalid syntax"
end
end
def test_install_contract_tracks_candidate_versions_and_client_source
gemfile = File.read(GEMFILE_PATH)
client_ref = gemfile[/gem "opencode-ruby".*?ref:\s*"([0-9a-f]{40})"/m, 1]
refute_nil client_ref, "Gemfile must pin opencode-ruby to an exact commit"
assert_includes @readme, %(gem "opencode-ruby", "= #{Opencode::VERSION}")
assert_includes @readme, %(gem "opencode-rails", "= #{Opencode::RAILS_VERSION}")
assert_includes @readme, %(ref: "#{client_ref}")
assert_includes @readme,
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
assert_match(/pushing a\s+`v\*` tag does not guarantee publication/, @readme)
assert_includes @readme, "pending trusted publisher for gem `opencode-rails`"
assert_includes @readme, "workflow `release.yml`"
assert_includes @readme, "environment `release`"
end
def test_quickstart_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, @quickstart)
assert_includes @quickstart,
'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes @quickstart, "directory: working_directory"
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes @quickstart, 'action: "allow"'
refute_includes @quickstart, "ConversationSandbox"
assert_includes @readme, "same absolute path in Rails and OpenCode"
assert_includes @readme, "`OPENCODE_DISABLE_PROJECT_CONFIG=1`"
assert_includes @readme, "before session permissions exist"
assert_match(/intentionally grants no\s+filesystem tools/, @readme)
assert_includes @readme, "permissions only when it creates a session"
assert_includes @readme, "recreate persisted sessions"
end
def test_quickstart_uses_the_enqueued_user_message
assert_includes @quickstart, "def perform(assistant_message, user_message)"
refute_includes @quickstart, ".where(role: :user).last"
end
def test_instrumentation_docs_match_the_configured_tracer_prefix
assert_includes @readme, "`assistant.response.started`"
assert_includes @readme, "Turn events flow through the injected tracer"
end
end

View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class ReleaseWorkflowTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_PATH = File.join(ROOT, ".github", "workflows", "release.yml")
SETUP_RUBY_ACTION = "ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600"
RELEASE_GEM_ACTION = "rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092"
def workflow
@workflow ||= YAML.safe_load(File.read(WORKFLOW_PATH), aliases: false)
end
def push_job
workflow.fetch("jobs").fetch("push")
end
def verify_job
workflow.fetch("jobs").fetch("verify")
end
def test_release_job_is_inert_on_non_github_runners
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
end
def test_release_job_keeps_the_trusted_publisher_boundary
assert_equal "release", push_job.fetch("environment")
assert_equal(
{ "contents" => "write", "id-token" => "write" },
push_job.fetch("permissions")
)
steps = push_job.fetch("steps")
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
assert_equal true, setup_ruby.dig("with", "bundler-cache")
assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION }
assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) }
refute steps.any? { |step| step.key?("run") }
end
def test_release_tag_must_match_the_gem_version_before_publish
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
refute_nil preflight
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
end
def test_verification_job_has_read_only_credentials
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
assert_equal false, checkout.dig("with", "persist-credentials")
end
def test_release_verifies_the_supported_matrix_and_installed_gems_before_publish
assert_equal %w[3.2 3.3 3.4 4.0], verify_job.dig("strategy", "matrix", "ruby")
assert_equal "verify", push_job.fetch("needs")
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
assert_includes commands, "bundle exec rake test"
assert_includes commands, "gem build opencode-rails.gemspec"
assert_includes commands, "bundle exec ruby -ropencode/version"
assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"'
assert_includes commands, 'GEM_PATH="${GEM_HOME}"'
refute_includes commands, "Gem.path.join"
assert_includes commands, 'client_gem="$registry_dir/opencode-ruby-${client_version}.gem"'
assert_includes commands, 'rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version'
assert_includes commands, 'gem fetch opencode-ruby --version "$client_version" --prerelease'
assert_includes commands, "--clear-sources --source https://rubygems.org"
assert_includes commands,
'gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands,
'gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands, "Gem.loaded_specs.fetch(name).full_gem_path"
assert_includes commands, "ruby -ropencode-rails"
end
end

16
test/version_test.rb Normal file
View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
require "test_helper"
class Opencode::VersionTest < Minitest::Test
GEMSPEC_PATH = File.expand_path("../opencode-rails.gemspec", __dir__)
def test_alpha_versions_and_runtime_dependency_stay_in_lockstep
specification = Gem::Specification.load(GEMSPEC_PATH)
dependency = specification.runtime_dependencies.find { |item| item.name == "opencode-ruby" }
refute_nil dependency
assert_equal Opencode::RAILS_VERSION, Opencode::VERSION
assert_equal "= #{Opencode::RAILS_VERSION}", dependency.requirement.to_s
end
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require "minitest/autorun"
require "yaml"
class WorkflowContractTest < Minitest::Test
ROOT = File.expand_path("..", __dir__)
WORKFLOW_DIRECTORY = File.join(ROOT, ".github", "workflows")
TEST_WORKFLOW_PATH = File.join(WORKFLOW_DIRECTORY, "test.yml")
ACTION_PINS = {
"actions/checkout" => "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
"ruby/setup-ruby" => "003a5c4d8d6321bd302e38f6f0ec593f77f06600",
"rubygems/release-gem" => "052cc82692552de3ef2b81fd670e41d13cba8092"
}.freeze
def test_matrix_covers_every_supported_ruby
workflow = YAML.safe_load(File.read(TEST_WORKFLOW_PATH), aliases: false)
versions = workflow.dig("jobs", "test", "strategy", "matrix", "ruby")
assert_equal %w[3.2 3.3 3.4 4.0], versions
end
def test_every_third_party_action_uses_its_reviewed_commit
action_uses = Dir[File.join(WORKFLOW_DIRECTORY, "*.{yml,yaml}")].sort.flat_map do |path|
workflow = YAML.safe_load(File.read(path), aliases: false)
workflow_uses(workflow)
end
assert_equal 7, action_uses.length
action_uses.each do |action_use|
action, separator, revision = action_use.rpartition("@")
assert_equal "@", separator
assert_equal ACTION_PINS.fetch(action), revision
assert_match(/\A[0-9a-f]{40}\z/, revision)
end
end
def test_action_discovery_only_reads_workflow_action_locations
workflow = YAML.safe_load(<<~YAML, aliases: false)
jobs:
reusable:
uses: "owner/workflow@revision"
with:
uses: ordinary-job-input
test:
steps:
- uses: "owner/action@revision"
with:
uses: ordinary-step-input
YAML
assert_equal %w[owner/workflow@revision owner/action@revision], workflow_uses(workflow)
end
private
def workflow_uses(node)
node.fetch("jobs").values.flat_map do |job|
action_uses = job.key?("uses") ? [job.fetch("uses")] : []
step_uses = job.fetch("steps", []).filter_map { |step| step["uses"] }
action_uses.concat(step_uses)
end
end
end