6 Commits

Author SHA1 Message Date
8ccfe0f7cf Rename Opencode::Rails::VERSION -> Opencode::RAILS_VERSION
Critical fix: defining Opencode::Rails as a module shadowed ::Rails
under Ruby's constant lookup whenever host code referenced top-level
Rails.something from inside the Opencode:: namespace.

Caught when host code in lib/opencode/containers/container.rb failed
to boot:

  /workspaces/app/lib/opencode/containers/container.rb:433:
    undefined method 'root' for module Opencode::Rails (NoMethodError)
      "notes.md" => Rails.root.join(...)

Ruby resolved 'Rails' to 'Opencode::Rails' first (the gem's version
namespace) before falling back to ::Rails. The fix removes the
intermediate module entirely:

  module Opencode
    RAILS_VERSION = "0.0.1.alpha1"
  end

Added a regression test (test_no_opencode_rails_module) so the
shadowing never sneaks back in. opencode-ruby uses Opencode::VERSION
for its own gem version; we can't double-up on that constant, so this
gem uses RAILS_VERSION as a sibling on the same Opencode module.
2026-05-20 05:22:23 -07:00
65ee701cae Add README + CHANGELOG for v0.0.1.alpha1
README (~140 LOC):
  - Why this gem exists (4 production needs opencode-ruby alone doesn't cover)
  - Install + quickstart (showing GenerateResponseJob wiring Session + Turn)
  - 12-constant API table with one-line role descriptions
  - Instrumentation + error-reporting adapter setup snippets
  - Position against opencode-ruby and Eric Guo's opencode_client

CHANGELOG documents what shipped, runtime deps, and known limitations
(apply_patch artifact drop, gem-side behavioral tests TBD, no Rails
generator yet). LICENSE was auto-generated by Gitea (MIT, 2026
ajaynomics) and is unchanged.
2026-05-20 05:17:14 -07:00
0611a2aa82 Add smoke tests for opencode-rails
14 tests across 4 files covering the gem's public surface:

  test/opencode/loading_test.rb (5 tests)
    - All 12 gem-provided constants resolve after require
    - Transitively-loaded opencode-ruby constants are present
    - Opencode::Session source_location points at this gem
    - Opencode::Client source_location points at opencode-ruby
    - Opencode::Rails::VERSION matches semver

  test/opencode/error_reporter_test.rb (3 tests)
    - report is no-op without adapter (returns nil)
    - report forwards error + opts to adapter
    - report works with no kwargs

  test/opencode/exchange_test.rb (3 tests)
    - Initializes with empty / nil / Array(coerced) messages
    - tool_artifacts(exclude:) doesn't raise on empty input

  test/opencode/artifact_test.rb (3 tests)
    - Filename/content/content_type/metadata readers
    - metadata defaults to {} when omitted
    - Trust metadata round-trips

Behavioral tests for Session/Turn/MessageArtifacts (AR + ActiveStorage
fixtures) stay in the host application (ajent-rails) where the
integration environment exists. Same pattern as opencode-ruby:
  - gem smoke tests prove load-time correctness
  - host tests prove integration correctness
2026-05-20 05:15:49 -07:00
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
57701a1701 Add Opencode::ErrorReporter pluggable adapter
Per the same pattern as Opencode::Instrumentation in opencode-ruby:
zero-dependency default (no-op), host plugs an adapter in to route
errors to Rails.error / Honeybadger / Sentry / etc.

Used by Session, Turn, MessageArtifacts (ported in the next commit) to
report swallowed exceptions and degraded paths without coupling the
gem to any specific error-tracking library.

  # config/initializers/opencode.rb
  Opencode::ErrorReporter.adapter = ->(error, **opts) {
    Rails.error.report(error, **opts)
  }
2026-05-20 05:11:00 -07:00
49f1ac7b9d Scaffold opencode-rails gem structure
- opencode-rails.gemspec (Ruby >= 3.2, MIT)
  - runtime: opencode-ruby ~> 0.0.1.alpha1
  - runtime: activerecord/activestorage/activesupport >= 7.1, < 9.0
    Per-sublibrary deps (not the rails umbrella) so host apps don't
    pull in ActionMailer/ActionCable/ActionView just to use this gem.
- lib/opencode-rails.rb (umbrella; requires opencode-ruby then leaves
  then session/turn)
- lib/opencode/rails/version.rb (Opencode::Rails::VERSION = 0.0.1.alpha1)
- Gemfile (sibling-path pull of opencode-ruby for local dev)
- Rakefile, .gitignore

Source files will be ported in the next commit.
2026-05-20 05:09:48 -07:00
38 changed files with 127 additions and 2127 deletions

View File

@@ -1,81 +0,0 @@
name: Push gem
on:
push:
tags:
- "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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: "4.0"
bundler-cache: true
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -1,37 +0,0 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-rails.gemspec
- name: Verify gem loads after install
run: |
client_dir="$(bundle show opencode-ruby)"
(cd "$client_dir" && gem build opencode-ruby.gemspec)
gem install "$client_dir"/opencode-ruby-*.gem --no-document
gem install opencode-rails-*.gem --no-document
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'

View File

@@ -1,120 +1,8 @@
# 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`. 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
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha5`, exposing
native parent-linked and configured session creation to Rails hosts.
## 0.0.1.alpha4 - 2026-07-12
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha4`, adding
current `session.status` idle handling and correct multi-assistant tool-loop
finalization for Rails turns.
## 0.0.1.alpha3 - 2026-07-10
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha3`, exposing
the session permission PATCH primitive to Rails host applications while
leaving reconciliation policy in each host.
## 0.0.1.alpha2 — 2026-05-20
### Changed
- `Opencode::Exchange` now emits `opencode.apply_patch.artifacts_dropped`
via the new `Opencode::Instrumentation.notify` fire-and-forget API
(introduced in opencode-ruby v0.0.1.alpha2) instead of
`.instrument(name, payload) { }` with an empty block. Cleaner read
at the call site; identical semantics on the wire (same event name,
same payload).
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha2` (was
`= 0.0.1.alpha1`). Versions stay in lockstep during alpha.
## 0.0.1.alpha1 — 2026-05-20
Initial public alpha. Extracted from a production Rails app where these objects shipped as in-tree library code before being carved out into a standalone gem.
Initial public alpha. Extracted from a production multi-product Rails app (`ajent-rails`) where these objects shipped under `lib/opencode/rails/` before being carved out into a standalone gem.
**Includes:**
@@ -141,6 +29,6 @@ Initial public alpha. Extracted from a production Rails app where these objects
**Known limitations (alpha):**
- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path.
- Smoke tests only inside the gem. Behavioral coverage currently lives in the host app that produced this code. A standalone gem-side test suite using Combustion is open work.
- Smoke tests only inside the gem (14 tests). Behavioral coverage lives in the host app that originally produced this code (`ajent-rails`'s `test/lib/opencode/rails/`). Standalone gem-side test suite using Combustion is open work.
- No generator (`rails g opencode:install`) yet.
- No Rails Engine integration — `require "opencode-rails"` is sufficient.

View File

@@ -1,73 +0,0 @@
# Contributing to opencode-rails
## Running the test suite
```bash
bundle install
bundle exec rake test
```
The smoke tests live in `test/opencode/`. They prove that:
- Every gem-provided constant resolves
- The opencode-ruby umbrella loads transitively
- Source locations point at the right gem
- The version constant is not under an `Opencode::Rails` module
(that would shadow `::Rails` in host apps; see comment in
`lib/opencode/rails_version.rb`)
- Public API contracts on the AR-coupled classes hold (Session, Turn,
MessageArtifacts) — verified via `Method#parameters`, not behavior
- Value objects (Artifact, SandboxFile, Transform, Impostor) round-trip
through their public interfaces
Behavioral tests for AR + ActiveStorage paths live in the host app
that produced this code. Same pattern as opencode-ruby — gem-side
smokes prove load correctness; host-side tests prove integration
correctness.
## Working on opencode-rails together with opencode-ruby
opencode-rails depends on opencode-ruby. During development of either
gem you frequently need changes in opencode-ruby to be picked up by
opencode-rails without going through a release cycle.
**Use Bundler's `local` config — not Gemfile conditionals.** Bundler
behavior must never depend on filesystem state inside the Gemfile.
```bash
# Once per dev machine. Replace the path with wherever you have
# opencode-ruby checked out.
bundle config local.opencode-ruby /path/to/opencode-ruby
# Then bundle install/update against the local copy:
bundle install
```
To switch back to the released version:
```bash
bundle config --delete local.opencode-ruby
bundle install
```
See [Bundler's documentation on local git overrides](https://bundler.io/v2.5/git.html#local).
## Releasing
This gem is in alpha. Versions ship as `0.0.x.alphaN` until the public
API stabilizes.
Coordinated releases with opencode-ruby:
1. In opencode-ruby: bump `Opencode::VERSION`, tag, push.
2. In opencode-rails: bump `Opencode::RAILS_VERSION`, update the
`add_runtime_dependency "opencode-ruby", "= X.Y.Z"` line in the
gemspec to match the new opencode-ruby version (alpha discipline:
pin exactly, not pessimistically). Tag, push.
3. In any consumer app: bump both `tag:` lines (or version pins) in
the Gemfile to the new versions; `bundle update opencode-ruby
opencode-rails`.
## Reporting issues
File at <https://github.com/ajaynomics/opencode-rails/issues>.

10
Gemfile
View File

@@ -1,7 +1,11 @@
source "https://rubygems.org"
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
# opencode-ruby is the underlying wire client. During alpha both gems
# evolve in lockstep; the gemspec pins a release version, but for local
# dev we pull from the sibling working copy so changes in opencode-ruby
# are picked up without a release cycle.
if File.exist?(File.expand_path("../opencode-ruby", __dir__))
gem "opencode-ruby", path: File.expand_path("../opencode-ruby", __dir__)
end
gemspec

135
README.md
View File

@@ -1,6 +1,6 @@
# opencode-rails
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/ajaynomics/opencode-ruby).
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://gitea.krishnan.ca/ajaynomics/opencode-ruby).
> **Alpha software.** API will change before 1.0. Pin to a specific version.
@@ -17,55 +17,17 @@ 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", "= 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"
gem "opencode-ruby" # wire client + Reply state machine
gem "opencode-rails" # AR-coupled session/turn/artifact stack
```
```bash
bundle install
```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
`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.
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.
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
## Quickstart
@@ -79,35 +41,13 @@ 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, user_message)
def perform(assistant_message)
conversation = assistant_message.conversation
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
)
user_message = conversation.messages.where(role: :user).last
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"])
session = Opencode::Session.new(
conversation,
@@ -120,15 +60,9 @@ class GenerateResponseJob < ApplicationJob
subject: conversation,
query_text: user_message.content,
client: client,
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)
},
session: session,
on_turn_finished: ->(result) {
# result.status #=> :completed | :cancelled | :error | :failed
# result.status #=> :completed | :error | :cancelled
# result.message #=> the AR row (reloaded)
# result.duration_ms
}
@@ -137,41 +71,15 @@ class GenerateResponseJob < ApplicationJob
private
def permission_rules_for(_conversation)
def permission_rules_for(conversation)
[
{ permission: "*", pattern: "*", action: "deny" }
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }
]
end
end
```
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.
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?`.
## What you get
@@ -194,18 +102,17 @@ Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Trace
## Instrumentation + error reporting
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:
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).
- `assistant.response.started`, `assistant.turn.finished`
- `assistant.stream.completed`, `assistant.stream.interrupted`
- `assistant.session.created`, `assistant.session.recreated_with_resend`
- `assistant.response.upstream_error`
Events emitted (non-exhaustive):
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
Instrumentation and error reporting are no-ops until the host wires adapters.
- `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.
## Position

View File

@@ -1,217 +0,0 @@
# frozen_string_literal: true
# examples/rails_integration.rb
#
# Production-shaped integration of opencode-rails into a Rails app.
# This file is NOT loaded by the gem at runtime — it's a reference
# blueprint. Drop the patterns into your app and adapt to your
# domain (Conversation/Message/User naming, ActiveStorage attachments,
# Turbo broadcasts).
#
# The pattern is extracted from a production Rails app where it ships
# multiple OpenCode-backed conversational products. It works for any
# host that has:
#
# - A "conversation" AR row that owns an `opencode_session_id:string`
# column and a has_many :messages association.
# - A "message" AR row with `role:string, status:string,
# parts_json:jsonb, content:text` (plus whatever fields your domain needs).
# - SolidQueue (or Sidekiq, GoodJob) for the background job.
# - Turbo for live streaming UX (optional but assumed).
#
# What each section demonstrates:
#
# 1. Initializer: route Instrumentation + ErrorReporter adapters
# to ActiveSupport::Notifications and Rails.error.report.
# 2. Job: orchestrate one turn with Opencode::Session + Opencode::Turn.
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
# 4. Permissions builder: per-product session permission rules.
#
# 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
# ----------------------------------------------------------------------
#
# Wire the two adapters opencode-ruby + opencode-rails ship with. The
# gems are silent by default; the host explicitly opts into routing
# events to its observability stack.
Rails.application.config.to_prepare do
# 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)
}
# opencode-rails: swallowed errors (Session abort failure, Turn
# callback exception, MessageArtifacts transform error) flow through
# this adapter. Wire your Honeybadger / Sentry / Rails.error reporter
# here.
Opencode::ErrorReporter.adapter = ->(error, **opts) {
Rails.error.report(error, **opts)
}
end
# ----------------------------------------------------------------------
# 2. app/jobs/generate_response_job.rb
# ----------------------------------------------------------------------
#
# One job per assistant message. Idempotent on the message row: if
# the message is already :completed or :error, the job is a no-op.
# The Turn class handles all the orchestration; this job is mostly
# wiring + error fallback.
class GenerateResponseJob < ApplicationJob
queue_as :llm
# 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, _user_message) { "GenerateResponseJob/#{message.conversation_id}" }
def perform(assistant_message, user_message)
return if assistant_message.terminal? # idempotent
conversation = assistant_message.conversation
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) },
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
)
# Turn: the orchestrator. Drives send -> stream -> recover ->
# finalize. Pass it the host's ReplyObserver factory so the gem's
# Reply state machine can bridge to your Turbo broadcasts.
Opencode::Turn.new(
message: assistant_message,
subject: conversation,
query_text: user_message.content,
client: client,
session_for: session,
observer_factory: ->(message) { ReplyStream.new(message: message) },
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}")
}
).call
rescue StandardError => e
Opencode::ErrorReporter.report(e, severity: :error,
context: { message_id: assistant_message.id, conversation_id: conversation.id })
assistant_message.update!(status: :error, content: "Sorry, something went wrong.")
end
private
def permission_rules_for(_conversation)
# Per-product permissions. The shape mirrors what
# opencode-ruby's Client#create_session expects in `permissions:`.
[
{ 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.
<<~CONTEXT
User: #{conversation.user.name}
Conversation: #{conversation.id}
CONTEXT
end
end
# ----------------------------------------------------------------------
# 3. app/services/reply_stream.rb
# ----------------------------------------------------------------------
#
# An Opencode::ReplyObserver implementation that bridges the gem's
# state-machine callbacks (part_added, part_changed, part_finalized) to
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
# the rendering.
#
# This is one of three places hosts customize: the renderer of a
# tool-call part. The other two are permission_rules_for and
# build_system_context above.
class ReplyStream
include Opencode::ReplyObserver
def initialize(message:)
@message = message
@parts_dom_id = "parts_message_#{message.id}"
end
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, index: index, message: @message }
)
end
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_#{index}_message_#{@message.id}",
partial: "messages/part",
locals: { part: part, index: index, message: @message }
)
end
end
# ----------------------------------------------------------------------
# That's it. The gem handles:
# - Idempotent session create/resolve with row-level locking
# - SSE stream consumption + reconnection on transport hiccups
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
# - 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.
# - Cost + token extraction from the final exchange
# - Artifact pipeline (MessageArtifacts.attach_from) with optional
# transforms (host-rendered HTML, JSON-to-PDF, etc.)
#
# Your job is the wiring. About 80 lines of Ruby gets you a production-
# grade chat agent.

View File

@@ -9,12 +9,6 @@
# 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"
@@ -23,7 +17,7 @@ require "active_support/core_ext/string/inflections" # demodulize, underscore, c
require "active_support/core_ext/string/filters" # squish, truncate
require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc.
require_relative "opencode/rails_version"
require_relative "opencode/rails/version"
require_relative "opencode/error_reporter"
# Tier 4 leaves (no deps on other rails-gem files)

View File

@@ -9,10 +9,10 @@ module Opencode
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
# call's input/metadata (write tool).
# - Opencode::SandboxFile#as_artifact — identity conversion of a
# sandbox-resident file (the default identity path).
# sandbox-resident file (the default path for Blackline + Raven).
#
# Transforms also return Artifacts — e.g. a host-rendered HTML
# artifact carrying a trust-metadata stamp.
# Transforms also return Artifacts; that's why FlightResultsTransform
# returns one with the host-rendered HTML + trust metadata stamp.
#
# An Artifact knows how to attach itself to a message, idempotently:
# it consults `message.artifacts` to skip if its filename is already

View File

@@ -65,13 +65,13 @@ module Opencode
eligible = file_entries.reject { |e| e[:type] == "delete" }
next if eligible.empty?
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
file_count: eligible.size,
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
message_id: part[:messageID],
session_id: part[:sessionID],
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
"extraction requires sandbox-read which is not yet wired into ResponseParser")
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
end
end
end

View File

@@ -11,9 +11,9 @@ module Opencode
# 1. A previous job retry attached the destination filename via the
# tool-extracted path (the agent wrote a file with that name and
# it landed before the trusted render did).
# 2. A pre-substrate code path persisted an agent-authored file
# under the destination filename the same-name stored-XSS
# attack the trust boundary exists to prevent.
# 2. A pre-substrate code path persisted an agent-authored HTML file
# with the destination filename (the historical AIGL exploit
# surface that motivated the trust boundary in the first place).
# 3. A previous transform version stamped different metadata and the
# trust check now correctly rejects it.
#

View File

@@ -9,7 +9,7 @@ module Opencode
#
# Two-line usage:
#
# Opencode::MessageArtifacts.new(message: m, feature: "chat", transforms: [])
# Opencode::MessageArtifacts.new(message: m, feature: "blackline", transforms: [])
# .attach_from(exchange: exchange, sandbox: sandbox)
#
# All four phases (tool extract, transform routing, impostor purge,
@@ -24,14 +24,15 @@ module Opencode
MAX_SANDBOX_ARTIFACTS = 20
# default_attach values:
# :all — every safe sandbox file that no transform claims falls
# through to identity attach. Use when the agent's `write`
# outputs are final document bytes the host serves back
# unchanged.
# :none — only transform-claimed files attach; everything else stays
# agent-internal. Use when the agent's sandbox is full of
# working scratch the user shouldn't see, and only specific
# filenames (claimed by transforms) become artifacts.
# :all — Blackline/Raven default. Every safe sandbox file that
# no transform claims falls through to identity attach.
# The agent's `write` outputs are final document bytes the
# host serves back unchanged.
# :none — AIGL. The agent's sandbox is full of internal working
# scratch (notes.md, map.md, timeline.md) plus the one
# file the transform claims (flight-results.json). Only
# transform-claimed files attach; everything else stays
# agent-internal.
def initialize(message:, feature:, transforms: [], default_attach: :all,
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
@message = message
@@ -80,15 +81,12 @@ 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
# opposite (only transform-claimed files attach) construct
# MessageArtifacts with default_attach: :none.
# Default identity path. Blackline/Raven default — every safe
# sandbox file that no transform claims attaches as-is. AIGL
# passes default_attach: :none so non-transform files (the
# agent's notes.md / map.md / timeline.md scratch) don't
# auto-attach.
attached += 1 if file.as_artifact.attach_to(message)
end
end

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.alpha9"
RAILS_VERSION = "0.0.1.alpha1"
end

View File

@@ -32,9 +32,9 @@ module Opencode
# 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. finalize paths that scan the whole sandbox), no mtime filter
# is applied — only safety + filetype.
# 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?

View File

@@ -15,8 +15,6 @@ 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:)
@@ -38,13 +36,7 @@ module Opencode
end
def content
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
File.read(path)
end
def content_type
@@ -60,18 +52,24 @@ module Opencode
# - Reject anything over the size cap (default
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
#
# Revalidates the opened file descriptor so a path swap between the
# sandbox scan and the read cannot redirect content outside the sandbox.
# The Sandbox scan filters non-files (directories, FIFOs) before
# yielding, so we don't re-check #file? here.
def safe?
with_safe_file { true }
rescue UnsafeFileError
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.
# path for Blackline + Raven, whose agents write document bytes
# directly to the sandbox and expect them attached unchanged.
def as_artifact
Artifact.new(
filename: basename,
@@ -79,46 +77,5 @@ 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

@@ -3,12 +3,13 @@
module Opencode
# Owns the lifecycle of an OpenCode session against a domain record.
#
# Consolidates a lifecycle that's easy to get subtly wrong: a host
# with N conversational products tends to grow N near-identical
# session-management code paths that drift on the details (which
# one took a row-level lock? which one swallowed teardown errors?).
# Single PORO, one place to change, no shotgun surgery when the
# protocol evolves.
# Three near-identical implementations of this lifecycle existed on
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
# had subtle differences (Blackline and Raven didn't take a row-level
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
# architectural review — a change to the lifecycle had to be made in
# three places that looked alike but disagreed on locking. This PORO
# is the consolidated role.
#
# Usage:
#
@@ -22,10 +23,11 @@ module Opencode
# service.abort!(client) # best-effort upstream abort
#
# The permissions_for: callable receives the record at mint! time and
# returns the permissions array for client.create_session. Per-product
# scoping (e.g. a record's workspace_key or tenant_id branching) lives
# returns the permissions array for client.create_session. Product-
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
# in the caller's lambda, not in this class — that keeps Session free
# of any reference to permission-building helpers.
# of any reference to permission-building helpers and preserves the
# rails-tier -> containers-tier boundary the design doc locks in.
#
# The on_error: callable is invoked when abort! catches an
# Opencode::Error during teardown. Callers wire their own observability

View File

@@ -9,8 +9,8 @@ module Opencode
# icon identifier.
#
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
# so any view that renders OpenCode tool calls — across whatever
# products the host runs — can do so consistently.
# so Blackline views, AIGL views, and any future OpenCode-backed
# feature can render tool calls consistently.
#
# ## Data shape (Opencode::Reply writes this into `parts_json`)
#

View File

@@ -6,16 +6,13 @@ module Opencode
# agent wrote" and "bytes the host signs and attaches."
#
# The default substrate path is identity: any sandbox file the
# allowlist accepts gets attached as-is. This works when the agent
# writes the final document bytes itself and the host just serves
# them back unchanged.
#
# Subclass Transform when the contract is structurally different:
# the agent writes raw data (e.g. JSON), and the **host** must
# render that data into trusted output (e.g. HTML) before attaching.
# The split matters when the resulting bytes get served inline from
# your app origin — an agent-written filename can't be permitted as
# stored-XSS, so a Transform draws the trust boundary.
# allowlist accepts gets attached as-is. Blackline and Raven use
# the default — their agents `write` final document bytes the host
# serves back unchanged. AIGL's contract is structurally different:
# the agent writes JSON, the **host** must render that JSON into
# trusted HTML before attaching, because the resulting HTML gets
# served inline from the app origin and an agent-written filename
# can't be permitted as stored-XSS.
#
# Subclass hooks (override these — none have a generic default
# that's safe to inherit):
@@ -31,8 +28,7 @@ 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: false; subclasses
# must verify host-authored metadata.
# vs download). Default: filename match.
# purge_impostors? — if true, before attaching the substrate
# deletes any existing attachment whose
# filename matches destination_filename
@@ -61,8 +57,8 @@ module Opencode
raise NotImplementedError, "#{self.class.name} must implement #render"
end
def trusted?(_attachment)
false
def trusted?(attachment)
attachment.filename.to_s == destination_filename
end
def purge_impostors?

View File

@@ -36,8 +36,8 @@ module Opencode
# smallest honest shape.
#
# Composition over inheritance: every product-specific concern is a
# collaborator passed in. Turn never sees any specific product by
# name — the orchestration shape is uniform.
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by
# name.
#
# Collaborators
# -------------
@@ -50,7 +50,7 @@ module Opencode
#
# observer_factory callable: ->(message) returning an observer that
# responds to #watch(reply). Concretely:
# ->(message) { MyApp::ReplyStream.new(message: message) }.
# ->(message) { Blackline::ReplyStream.new(...) }.
#
# system_context callable: ->(subject) -> String system prompt.
#
@@ -213,6 +213,12 @@ 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)
@@ -254,22 +260,6 @@ 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
@@ -303,8 +293,7 @@ module Opencode
@client.stream_events(
session_id: session_id,
reply: reply,
on_activity_tick: activity_tick,
on_subscribed: on_subscribed
on_activity_tick: activity_tick
) do |event|
event_count += 1
reply.apply(event)
@@ -319,13 +308,6 @@ 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,10 +24,8 @@ 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: 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.
# guarded (the cleanpath of the destination must start with the
# sandbox dir prefix, no symlink trickery).
class UploadedFilesPrompt
attr_reader :text, :sandbox_file_names
@@ -54,77 +52,24 @@ module Opencode
[
raw,
"",
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly before responding:",
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
*file_instructions
].join("\n").strip
end
def copy_to_sandbox(file)
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
FileUtils.mkdir_p(@sandbox_path)
sandbox_name = @sandbox_name_for.call(file).to_s
unless sandbox_name == File.basename(sandbox_name) && !%w[. ..].include?(sandbox_name)
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)
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
end
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
File.open(dest, "wb") { |f| f.write(file.download) }
Placement.new(sandbox_name, dest)
end
# Tiny value pair returned by copy_to_sandbox: the canonical filename

View File

@@ -1,12 +1,12 @@
# frozen_string_literal: true
require_relative "lib/opencode/rails_version"
require_relative "lib/opencode/rails/version"
Gem::Specification.new do |spec|
spec.name = "opencode-rails"
spec.version = Opencode::RAILS_VERSION
spec.authors = ["Ajay Krishnan"]
spec.email = ["opencode-rails@ajay.to"]
spec.email = ["ajay@krishnan.ca"]
spec.summary = "Production-grade Rails integration for OpenCode."
spec.description = <<~DESC
@@ -19,26 +19,22 @@ Gem::Specification.new do |spec|
production-grade OpenCode streaming without rolling your own
boilerplate.
DESC
spec.homepage = "https://github.com/ajaynomics/opencode-rails"
spec.homepage = "https://gitea.krishnan.ca/ajaynomics/opencode-rails"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.metadata["changelog_uri"] = "#{spec.homepage}/src/branch/main/CHANGELOG.md"
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
spec.files = Dir.glob("lib/**/*.rb") +
Dir.glob("examples/**/*.rb") +
%w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec]
spec.require_paths = ["lib"]
# The opencode-ruby gem provides the wire-level Client + Reply primitives
# 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.alpha9"
spec.add_runtime_dependency "marcel", "~> 1.0"
# this gem builds on. Versions are kept in lockstep during the alpha
# phase; will relax to a looser pessimistic pin once both gems stabilize.
spec.add_runtime_dependency "opencode-ruby", "~> 0.0.1.alpha1"
# Rails sub-libraries used at runtime. Depending on these individually
# (instead of the `rails` umbrella) avoids forcing host apps to load

View File

@@ -1,67 +0,0 @@
# 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

@@ -4,8 +4,8 @@ require "test_helper"
# Smoke test for Opencode::Artifact — verifies the value-object surface
# (filename/content/content_type readers). Behavioral tests around how
# the host's ActiveStorage-backed records build artifact collections
# live in the host's test suite.
# the host's ActiveStorage-backed AIGL::Trip/etc. records build artifact
# collections live in the host's test suite.
class Opencode::ArtifactTest < Minitest::Test
def test_value_object_readers
artifact = Opencode::Artifact.new(

View File

@@ -47,29 +47,4 @@ class Opencode::ErrorReporterTest < Minitest::Test
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
assert invoked, "Adapter should be invoked even with no kwargs"
end
def test_adapter_exceptions_propagate
# If the host's adapter itself raises (Honeybadger HTTP failure,
# Sentry quota error, etc.) the gem must propagate — silently
# swallowing the adapter's own errors would hide an outage from
# operators who think their error tracker is healthy.
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
raise StandardError, "adapter blew up"
}
raised = assert_raises(StandardError) do
Opencode::ErrorReporter.report(RuntimeError.new("original"))
end
assert_equal "adapter blew up", raised.message
end
def test_report_returns_adapter_return_value
# Useful for hosts wanting Rails.error.report's standard return
# (the error itself). Verifies the call shape doesn't transform it.
sentinel = Object.new
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
result = Opencode::ErrorReporter.report(StandardError.new("x"))
assert_same sentinel, result
end
end

View File

@@ -1,26 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Impostor. Wraps an ActiveStorage
# attachment that's been replaced by a Transform-rendered artifact.
# Behavioral tests against real ActiveStorage::Attachment live in
# the host application.
class Opencode::ImpostorTest < Minitest::Test
def test_initialize_takes_attachment_keyword
params = Opencode::Impostor.instance_method(:initialize).parameters
assert_includes params, [ :keyreq, :attachment ],
"Impostor must require an attachment: keyword (ActiveStorage::Attachment-like)"
end
def test_public_api
assert_equal %i[filename purge!].sort,
Opencode::Impostor.instance_methods(false).sort
end
def test_filename_delegates_to_attachment
attachment_double = Struct.new(:filename).new("legacy.html")
impostor = Opencode::Impostor.new(attachment: attachment_double)
assert_equal "legacy.html", impostor.filename
end
end

View File

@@ -1,7 +1,6 @@
# 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,
@@ -37,27 +36,16 @@ class Opencode::LoadingTest < Minitest::Test
end
end
# Match the gem's own lib path, not merely any parent directory. GitHub's
# checkout layout nests Bundler under /opencode-rails/opencode-rails, so a
# broad directory-name assertion falsely classifies a bundled
# opencode-ruby source path as belonging to this gem.
GEM_SOURCE_PATTERN = lambda do |name, file|
%r{/#{Regexp.escape(name)}(?:-[^/]+)?/lib/opencode/#{Regexp.escape(file)}\.rb\z}
end
def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first
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"
assert_match %r{/opencode-rails/}, location,
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
end
def test_client_constant_points_at_opencode_ruby
location = Opencode::Client.instance_method(:initialize).source_location.first
assert_match GEM_SOURCE_PATTERN.call("opencode-ruby", "client"), location,
assert_match %r{/opencode-ruby/}, location,
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
refute_match GEM_SOURCE_PATTERN.call("opencode-rails", "client"), location,
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
end
def test_version_constant
@@ -73,24 +61,4 @@ 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

@@ -1,56 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::MessageArtifacts (the idempotent
# ActiveStorage-backed artifact attachment pipeline). Behavioral
# 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) }
assert_includes by_kind[:keyreq], :message,
"MessageArtifacts must require a message: keyword"
assert_includes by_kind[:keyreq], :feature,
"MessageArtifacts must require a feature: keyword (used in error reports)"
assert_includes by_kind[:key] || [], :transforms,
"MessageArtifacts must accept an optional transforms: keyword"
end
def test_public_api_is_attach_from
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

@@ -1,208 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "socket"
require "tmpdir"
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
# Smoke test for Opencode::SandboxFile: instantiate against a real
# pathname, verify basename/size/content/content_type readers and the
# identity conversion to Opencode::Artifact via #as_artifact.
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
# escape; it expects a String with trailing separator so that
# /sandbox-1 doesn't false-positive on /sandbox-10/foo.
@sandbox_prefix = File.join(@tmpdir, "")
end
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
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
assert_equal "notes.md", file.basename
assert file.size.positive?
assert_equal "# hello\nworld\n", file.content
assert file.safe?, "small text file inside sandbox should be safe"
end
def test_content_type_detection_via_marcel
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
# Marcel detects .md as text/markdown.
assert_match(/markdown|text/, file.content_type)
end
def test_safe_rejects_files_over_size_cap
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5
)
refute file.safe?, "file larger than max_bytes must be unsafe"
end
def test_as_artifact_returns_opencode_artifact_value
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
artifact = file.as_artifact
assert_instance_of Opencode::Artifact, artifact
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

@@ -1,53 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
require "marcel" # SandboxFile (yielded by Sandbox#files) needs marcel
# Smoke test for Opencode::Sandbox: instantiate against a real tmpdir,
# verify #path, #exists?, and that #files / #file return empty / nil
# when no sandbox files are present. Behavioral coverage of actual file
# enumeration lives in the host application where real per-product
# sandbox configurations exercise the path.
class Opencode::SandboxTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-test-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_path_and_exists_when_directory_present
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_equal @tmpdir, sandbox.path
assert sandbox.exists?
end
def test_exists_false_when_path_missing
sandbox = Opencode::Sandbox.new(path: File.join(@tmpdir, "missing"))
refute sandbox.exists?
end
def test_files_returns_enumerator_yielding_nothing_when_empty
sandbox = Opencode::Sandbox.new(path: @tmpdir)
# No block given => Enumerator.
assert_kind_of Enumerator, sandbox.files
assert_equal [], sandbox.files.to_a
end
def test_files_yields_sandbox_files_for_real_entries
File.write(File.join(@tmpdir, "notes.md"), "x")
File.write(File.join(@tmpdir, "map.md"), "y")
sandbox = Opencode::Sandbox.new(path: @tmpdir)
basenames = sandbox.files.map(&:basename).sort
assert_equal %w[map.md notes.md], basenames
end
def test_file_returns_nil_for_missing_relative_name
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_nil sandbox.file("nope.txt")
end
end

View File

@@ -1,27 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Session. Behavioral coverage (idempotent
# ensure!/recreate!/abort! with row-level locking, race-safety,
# permissions_for callable handoff) lives in the host application
# where AR fixtures + a real ActiveRecord row exist.
class Opencode::SessionTest < Minitest::Test
def test_initialize_takes_record_and_two_keyword_callables
params = Opencode::Session.instance_method(:initialize).parameters
assert_includes params, [ :req, :record ],
"Session must take a positional record (an AR row with #with_lock, #title, etc.)"
assert_includes params, [ :keyreq, :permissions_for ],
"Session must require a permissions_for: callable (host-injected per-product permissions)"
assert_includes params, [ :key, :on_error ],
"Session must accept an optional on_error: callable for adapter-style error reporting"
end
def test_public_api_is_ensure_recreate_abort_just_created
methods = Opencode::Session.instance_methods(false).sort
assert_equal %i[abort! ensure! just_created? recreate!].sort, methods.sort,
"Session's public surface should be exactly: ensure!/recreate!/abort!/just_created?. " \
"Found: #{methods.inspect}"
end
end

View File

@@ -1,55 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Smoke tests for Opencode::ToolDisplay — the view-model that converts
# raw tool-part hashes into Turbo-Stream-friendly props. We exercise
# the predicate surface for the canonical 'read' tool plus the
# unknown-tool fallback. Exhaustive per-tool render tests live in the
# host where the renderer templates are exercised.
class Opencode::ToolDisplayTest < Minitest::Test
def test_known_read_tool_canonicalization
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "read", "status" => "completed",
"input" => { "filePath" => "/sandbox/notes.md" }
)
assert_equal "read", display.canonical_tool
assert display.known?
assert display.completed?
assert display.terminal?
refute display.errored?
refute display.in_flight?
end
def test_running_status
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "read", "status" => "running")
assert display.in_flight?
refute display.terminal?
refute display.completed?
end
def test_errored_status
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "edit", "status" => "error", "error" => "permission denied"
)
assert display.errored?
assert display.terminal?
refute display.completed?
end
def test_unknown_tool_falls_back_gracefully
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "wat", "status" => "completed")
refute display.known?,
"Unknown tools must not claim to be known — host renderer dispatches a fallback view"
refute_nil display.canonical_tool,
"Unknown tools still need a canonical_tool so DOM ids stay stable"
end
def test_nil_part_initializes_safely
# ToolDisplay tolerates a nil part because callers sometimes pass
# message.parts_json entries that aren't tool parts.
display = Opencode::ToolDisplay.new(nil)
refute display.known?
end
end

View File

@@ -1,67 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Smoke test for Opencode::Transform — the base class for
# content-rewriting transforms. It is intentionally abstract:
# #source_filename, #destination_filename, and #render all raise
# NotImplementedError. Subclasses (host-side) provide the meat.
# These tests document the abstract contract.
class Opencode::TransformTest < Minitest::Test
def test_source_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename }
assert_match(/must implement #source_filename/, err.message)
end
def test_destination_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename }
assert_match(/must implement #destination_filename/, err.message)
end
def test_render_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) }
assert_match(/must implement #render/, err.message)
end
def test_purge_impostors_defaults_to_false
refute Opencode::Transform.new.purge_impostors?,
"Default #purge_impostors? must be false — conservative opt-in by subclasses"
end
# A trivial concrete subclass exercises the defaults that DO exist
# (#applies_to? and #owned_filenames delegate to the
# two abstract filename methods).
class FakeTransform < Opencode::Transform
def source_filename = "agent-output.json"
def destination_filename = "rendered.html"
end
Attachment = Struct.new(:filename, keyword_init: true)
Basenamed = Struct.new(:basename, keyword_init: true)
def test_applies_to_matches_source_filename_by_default
transform = FakeTransform.new
matching = Basenamed.new(basename: "agent-output.json")
other = Basenamed.new(basename: "something-else.json")
assert transform.applies_to?(matching)
refute transform.applies_to?(other)
end
def test_trusted_fails_closed_by_default
transform = FakeTransform.new
destination = Attachment.new(filename: "rendered.html")
refute transform.trusted?(destination)
end
def test_owned_filenames_is_source_and_destination
assert_equal %w[agent-output.json rendered.html],
FakeTransform.new.owned_filenames
end
def test_error_is_a_subclass_of_standarderror
assert_operator Opencode::Transform::Error, :<, StandardError,
"Transform::Error must be rescuable by `rescue StandardError`"
end
end

View File

@@ -1,210 +0,0 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
# 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
].freeze
OPTIONAL_INIT_KEYS = %i[
on_finalized on_turn_finished on_activity_tick
empty_stream_retry_delay final_exchange_timeout
final_exchange_retry_delay error_fallback_content error_feature
].freeze
def test_required_keyword_arguments
params = Opencode::Turn.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal REQUIRED_INIT_KEYS.sort, required,
"Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}"
end
def test_optional_keyword_arguments_match_documented_surface
params = Opencode::Turn.instance_method(:initialize).parameters
optional = params.select { |kind, _| kind == :key }.map(&:last).sort
assert_equal OPTIONAL_INIT_KEYS.sort, optional,
"Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}"
end
def test_public_surface_is_call_only
# Turn is an orchestrator; the only public verb is #call. Everything
# else is internal. Locking this prevents helpers from accidentally
# bleeding into the public API.
assert_equal [ :call ], Opencode::Turn.instance_methods(false)
end
def test_result_is_a_value_object_with_status_predicates
fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new(
cost: 0.012, input_tokens: 100, output_tokens: 50
)
result = Opencode::Turn::Result.new(
status: :completed, message: fake_message, duration_ms: 1234
)
assert result.completed?
refute result.cancelled?
refute result.errored?
refute result.failed?
assert_equal 1234, result.duration_ms
assert_equal 0.012, result.cost
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

@@ -1,188 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
# inversion (per D14 in the design doc) means we can exercise the
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
# fixtures. Behavioral tests covering ActiveStorage attached_blob
# enumeration live in the host application.
class Opencode::UploadedFilesPromptTest < Minitest::Test
# Minimal stub for a user message: #content (the raw user text) and
# #files (an ActiveStorage-like collection with #attached?). Real
# behavior is exercised in the host's test suite.
FakeMessage = Struct.new(:content, :files, keyword_init: true)
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-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_initialize_takes_three_required_keywords
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
end
def test_text_returns_raw_content_when_no_files_attached
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
prompt = Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(file) { file.filename.to_s }
)
assert_equal "hello world", prompt.text,
"No attached files => text is the raw user content"
assert_equal({}, prompt.sandbox_file_names,
"No attached files => sandbox_file_names map stays empty")
end
def test_public_surface
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

View File

@@ -1,82 +0,0 @@
# 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

@@ -1,82 +0,0 @@
# 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

View File

@@ -1,16 +0,0 @@
# 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

@@ -1,67 +0,0 @@
# 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