24 Commits

Author SHA1 Message Date
9937e45f5e Finalize alpha9 release safeguards 2026-07-20 20:51:01 -07:00
427bd47648 Prepare repaired alpha9 release 2026-07-20 20:04:59 -07:00
b02bcc74ef Harden alpha8 publication safety 2026-07-20 14:26:22 -07:00
a9add2a7c1 Prepare opencode-rails alpha8 2026-07-19 22:21:41 -07:00
61beb17f55 Upgrade checkout action to v7 2026-07-19 18:15:10 -07:00
8b78a558e1 Harden workflow action pin discovery 2026-07-19 17:29:10 -07:00
4d975d99b3 Pin CI actions and test Ruby 4 2026-07-19 16:23:20 -07:00
ajay@krishnan.ca
b27d39edf4 Merge pull request 'Run trusted gem releases only on GitHub' (#1) from fix/github-only-release-runner-guard-20260719 into main 2026-07-19 12:37:48 -07:00
75e4435d9d Test GitHub-only gem release boundary 2026-07-19 06:50:51 -07:00
6148fc67da Run gem releases only on GitHub 2026-07-19 04:30:50 -07:00
Ajay Krishnan
deeccb119e Merge pull request #3 from ajaynomics/docs/readme-executable-quickstart
docs: make alpha7 Rails quickstart executable
2026-07-18 15:31:57 -07:00
fe953020d5 docs: make Rails quickstart match Turn API 2026-07-18 15:29:57 -07:00
Ajay Krishnan
2a391ccad1 Merge pull request #2 from ajaynomics/fix/turn-subscribe-before-prompt-alpha7
Submit Rails turns after SSE readiness
2026-07-18 14:39:25 -07:00
7744fe211a Submit Rails turns after SSE readiness 2026-07-18 14:37:24 -07:00
Ajay Krishnan
17025f0ed9 Merge pull request #1 from ajaynomics/release/alpha6
Release opencode-rails 0.0.1.alpha6
2026-07-18 13:43:15 -07:00
451ef97b9f Build packaged dependency from its source root 2026-07-18 13:41:14 -07:00
b0e8cf8e20 Make loading provenance assertion path-specific 2026-07-18 13:40:03 -07:00
de51ff3a45 Make lockstep dependency CI-resolvable 2026-07-18 13:38:22 -07:00
ca08bb36e8 Add trusted gem publishing 2026-07-18 13:32:36 -07:00
a5bd656144 Release opencode-rails alpha6 2026-07-18 13:31:13 -07:00
ff6187118d Release opencode-rails alpha5
Some checks failed
Test / test (3.2) (push) Failing after 10s
Test / test (3.3) (push) Failing after 11s
Test / test (3.4) (push) Failing after 11s
2026-07-15 20:20:17 -07:00
3af78b9716 Release opencode-rails alpha4
Some checks failed
Test / test (3.2) (push) Failing after 1s
Test / test (3.3) (push) Failing after 1s
Test / test (3.4) (push) Failing after 1s
2026-07-12 01:55:56 -07:00
83b0a4ee77 Release alpha3 client dependency
Some checks failed
Test / test (3.3) (push) Failing after 1s
Test / test (3.4) (push) Failing after 1s
Test / test (3.2) (push) Failing after 36s
2026-07-10 19:29:31 -07:00
9b0c4cd3cd Initial public release v0.0.1.alpha2
Some checks failed
Test / test (3.2) (push) Failing after 9m43s
Test / test (3.3) (push) Failing after 10m0s
Test / test (3.4) (push) Failing after 10m0s
opencode-rails — production-grade Rails integration for OpenCode.

Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle
(idempotent ensure!/recreate!/abort! with row-level locks), a Turn
orchestrator driving the Reply state machine and recovering from
session-not-found, an artifact pipeline backed by ActiveStorage,
sandbox seeding, and tool-display value objects for Turbo Stream
broadcasts. Drop into any Rails 7.1+ app that wants production-grade
OpenCode streaming without rolling boilerplate.

What this version ships:
  - Opencode::Session (AR-coupled lifecycle, row-level locks)
  - Opencode::Turn (Reply state machine, session-not-found recovery)
  - Opencode::Exchange (one turn = one request/response unit)
  - Opencode::Impostor (deterministic mock for tests)
  - Opencode::Sandbox / SandboxFile (per-session FS scratch space)
  - Opencode::Transform (host-rendered artifact pipeline)
  - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed)
  - Opencode::UploadedFilesPrompt (system-prompt builder)
  - Opencode::ToolDisplay (Turbo Stream value objects)
  - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.)
  - examples/rails_integration.rb — canonical wiring blueprint

53 smoke tests. CI on Ruby 3.2/3.3/3.4.

Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2,
activerecord/activestorage/activesupport >= 7.1, < 9.0.

See CHANGELOG.md for the alpha1 -> alpha2 delta.
2026-05-25 06:49:09 -07:00
38 changed files with 2127 additions and 127 deletions

81
.github/workflows/release.yml vendored Normal file
View File

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

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
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,8 +1,120 @@
# Changelog # 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 ## 0.0.1.alpha1 — 2026-05-20
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. 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.
**Includes:** **Includes:**
@@ -29,6 +141,6 @@ Initial public alpha. Extracted from a production multi-product Rails app (`ajen
**Known limitations (alpha):** **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. - 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 (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. - 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.
- No generator (`rails g opencode:install`) yet. - No generator (`rails g opencode:install`) yet.
- No Rails Engine integration — `require "opencode-rails"` is sufficient. - No Rails Engine integration — `require "opencode-rails"` is sufficient.

73
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,73 @@
# 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,11 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# opencode-ruby is the underlying wire client. During alpha both gems gem "opencode-ruby",
# evolve in lockstep; the gemspec pins a release version, but for local git: "https://github.com/ajaynomics/opencode-ruby.git",
# dev we pull from the sibling working copy so changes in opencode-ruby ref: "65a44ca1502926d533e6b4b6692779fa39740218"
# 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 gemspec

135
README.md
View File

@@ -1,6 +1,6 @@
# opencode-rails # 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://gitea.krishnan.ca/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://github.com/ajaynomics/opencode-ruby).
> **Alpha software.** API will change before 1.0. Pin to a specific version. > **Alpha software.** API will change before 1.0. Pin to a specific version.
@@ -17,17 +17,55 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
## Install ## Install
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
```ruby ```ruby
# Gemfile # Gemfile
gem "opencode-ruby" # wire client + Reply state machine gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails" # AR-coupled session/turn/artifact stack gem "opencode-rails", "= 0.0.1.alpha9"
```
Until publication is verified, validate this candidate checkout against the
exact `opencode-ruby` source it was tested with:
```ruby
# Gemfile
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails",
path: "../opencode-rails"
``` ```
```bash ```bash
bundle install bundle install
``` ```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives. Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
`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.
## Quickstart ## Quickstart
@@ -41,13 +79,35 @@ Opencode::ErrorReporter.adapter = ->(error, **opts) {
} }
``` ```
```ruby
# app/services/noop_reply_observer.rb
#
# Turn requires an observer factory even when the app does not need live
# partial rendering. For a streaming UI, replace this with an observer that
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
class NoopReplyObserver
include Opencode::ReplyObserver
def watch(reply)
reply.add_observer(self)
self
end
end
```
```ruby ```ruby
# app/jobs/generate_response_job.rb # app/jobs/generate_response_job.rb
class GenerateResponseJob < ApplicationJob class GenerateResponseJob < ApplicationJob
def perform(assistant_message) def perform(assistant_message, user_message)
conversation = assistant_message.conversation conversation = assistant_message.conversation
user_message = conversation.messages.where(role: :user).last unless user_message.conversation_id == conversation.id
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"]) raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: working_directory
)
session = Opencode::Session.new( session = Opencode::Session.new(
conversation, conversation,
@@ -60,9 +120,15 @@ class GenerateResponseJob < ApplicationJob
subject: conversation, subject: conversation,
query_text: user_message.content, query_text: user_message.content,
client: client, client: client,
session: session, session_for: session,
observer_factory: ->(_message) { NoopReplyObserver.new },
system_context: ->(record) { "You are assisting with #{record.title}." },
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) { on_turn_finished: ->(result) {
# result.status #=> :completed | :error | :cancelled # result.status #=> :completed | :cancelled | :error | :failed
# result.message #=> the AR row (reloaded) # result.message #=> the AR row (reloaded)
# result.duration_ms # result.duration_ms
} }
@@ -71,15 +137,41 @@ class GenerateResponseJob < ApplicationJob
private private
def permission_rules_for(conversation) def permission_rules_for(_conversation)
[ [
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" } { permission: "*", pattern: "*", action: "deny" }
] ]
end end
end end
``` ```
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`. Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
project configuration, plugins, and instructions while opening a directory,
before session permissions exist; the wildcard deny rule cannot constrain that
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
mounted at the same absolute path in Rails and OpenCode. Treat the server's
global configuration, plugins, and instructions as privileged administrator
code and verify the setting against the deployed OpenCode version.
This quickstart intentionally grants no filesystem tools: OpenCode permissions
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
process also has an independent container or operating-system boundary.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions
with a client scoped to the new directory; an existing session ID does not
prove that the new policy was applied.
The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
`#error!(content)`. A non-no-op observer may impose additional record methods
for its own live snapshots.
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
constructor is intentionally explicit and may change before 1.0, so keep this
wiring in one host service/job and keep the gem source pinned exactly.
## What you get ## What you get
@@ -102,17 +194,18 @@ Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Trace
## Instrumentation + error reporting ## Instrumentation + error reporting
The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above). The wire client emits `opencode.request` and artifact extraction emits
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
Turn events flow through the injected tracer; with the Quickstart's
`assistant.` prefix they include:
Events emitted (non-exhaustive): - `assistant.response.started`, `assistant.turn.finished`
- `assistant.stream.completed`, `assistant.stream.interrupted`
- `assistant.session.created`, `assistant.session.recreated_with_resend`
- `assistant.response.upstream_error`
- `opencode.turn.started`, `opencode.turn.finished` Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
- `opencode.stream.completed`, `opencode.stream.interrupted` Instrumentation and error reporting are no-ops until the host wires adapters.
- `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 ## Position

View File

@@ -0,0 +1,217 @@
# 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,6 +9,12 @@
# rename work. # rename work.
require "opencode-ruby" 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/blank" # blank?, present?, presence
require "active_support/core_ext/object/try" require "active_support/core_ext/object/try"
@@ -17,7 +23,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/string/filters" # squish, truncate
require "active_support/core_ext/numeric/time" # 2.seconds, 5.minutes, etc. 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" require_relative "opencode/error_reporter"
# Tier 4 leaves (no deps on other rails-gem files) # 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 # - Opencode::Exchange.tool_artifacts — content lives inside a tool
# call's input/metadata (write tool). # call's input/metadata (write tool).
# - Opencode::SandboxFile#as_artifact — identity conversion of a # - Opencode::SandboxFile#as_artifact — identity conversion of a
# sandbox-resident file (the default path for Blackline + Raven). # sandbox-resident file (the default identity path).
# #
# Transforms also return Artifacts; that's why FlightResultsTransform # Transforms also return Artifacts — e.g. a host-rendered HTML
# returns one with the host-rendered HTML + trust metadata stamp. # artifact carrying a trust-metadata stamp.
# #
# An Artifact knows how to attach itself to a message, idempotently: # An Artifact knows how to attach itself to a message, idempotently:
# it consults `message.artifacts` to skip if its filename is already # 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" } eligible = file_entries.reject { |e| e[:type] == "delete" }
next if eligible.empty? next if eligible.empty?
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped", Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
file_count: eligible.size, file_count: eligible.size,
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5), relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
message_id: part[:messageID], message_id: part[:messageID],
session_id: part[:sessionID], session_id: part[:sessionID],
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \ 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 end
end end

View File

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

View File

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

View File

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

View File

@@ -32,9 +32,9 @@ module Opencode
# Yields SandboxFile values for every file in the sandbox that # Yields SandboxFile values for every file in the sandbox that
# passes its own #safe? predicate AND was modified after the cutoff. # passes its own #safe? predicate AND was modified after the cutoff.
# When `after:` is nil (callers without a user_message handle, e.g. # When `after:` is nil (callers without a user_message handle
# AIGL on certain finalize paths), no mtime filter is applied — # e.g. finalize paths that scan the whole sandbox), no mtime filter
# only safety + filetype. # is applied — only safety + filetype.
def files(after: nil) def files(after: nil)
return enum_for(:files, after: after) unless block_given? return enum_for(:files, after: after) unless block_given?
return unless exists? return unless exists?

View File

@@ -15,6 +15,8 @@ module Opencode
# not here — the file doesn't know which turn opened "after." That's # not here — the file doesn't know which turn opened "after." That's
# a property of the scan, not a property of the file. # a property of the scan, not a property of the file.
class SandboxFile class SandboxFile
UnsafeFileError = Class.new(Opencode::Error)
attr_reader :path, :sandbox_prefix attr_reader :path, :sandbox_prefix
def initialize(path:, sandbox_prefix:, max_bytes:) def initialize(path:, sandbox_prefix:, max_bytes:)
@@ -36,7 +38,13 @@ module Opencode
end end
def content def content
File.read(path) with_safe_file do |file|
content = file.read(@max_bytes + 1) || "".b
if content.bytesize > @max_bytes
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
end
content
end
end end
def content_type def content_type
@@ -52,24 +60,18 @@ module Opencode
# - Reject anything over the size cap (default # - Reject anything over the size cap (default
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB). # Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
# #
# The Sandbox scan filters non-files (directories, FIFOs) before # Revalidates the opened file descriptor so a path swap between the
# yielding, so we don't re-check #file? here. # sandbox scan and the read cannot redirect content outside the sandbox.
def safe? def safe?
return false if File.symlink?(path) with_safe_file { true }
return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix) rescue UnsafeFileError
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 false
end end
# Identity conversion: this sandbox file → an Artifact carrying the # Identity conversion: this sandbox file → an Artifact carrying the
# file's own bytes. Used by the substrate's default (non-transform) # file's own bytes. Used by the substrate's default (non-transform)
# path for Blackline + Raven, whose agents write document bytes # path, where the agent writes document bytes directly to the
# directly to the sandbox and expect them attached unchanged. # sandbox and the host serves them back unchanged.
def as_artifact def as_artifact
Artifact.new( Artifact.new(
filename: basename, filename: basename,
@@ -77,5 +79,46 @@ module Opencode
content_type: content_type content_type: content_type
) )
end 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
end end

View File

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

View File

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

View File

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

View File

@@ -36,8 +36,8 @@ module Opencode
# smallest honest shape. # smallest honest shape.
# #
# Composition over inheritance: every product-specific concern is a # Composition over inheritance: every product-specific concern is a
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by # collaborator passed in. Turn never sees any specific product by
# name. # name — the orchestration shape is uniform.
# #
# Collaborators # Collaborators
# ------------- # -------------
@@ -50,7 +50,7 @@ module Opencode
# #
# observer_factory callable: ->(message) returning an observer that # observer_factory callable: ->(message) returning an observer that
# responds to #watch(reply). Concretely: # responds to #watch(reply). Concretely:
# ->(message) { Blackline::ReplyStream.new(...) }. # ->(message) { MyApp::ReplyStream.new(message: message) }.
# #
# system_context callable: ->(subject) -> String system prompt. # system_context callable: ->(subject) -> String system prompt.
# #
@@ -213,12 +213,6 @@ module Opencode
emit_session_created_if_new emit_session_created_if_new
validate_session!(session_id) 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) stream_result = stream_response(session_id)
exchange = fetch_current_exchange(session_id) exchange = fetch_current_exchange(session_id)
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange) stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
@@ -260,6 +254,22 @@ module Opencode
last_activity_touch_at = stream_started_at last_activity_touch_at = stream_started_at
first_token_at = nil first_token_at = nil
event_count = 0 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 begin
release_active_record_connections release_active_record_connections
@@ -293,7 +303,8 @@ module Opencode
@client.stream_events( @client.stream_events(
session_id: session_id, session_id: session_id,
reply: reply, reply: reply,
on_activity_tick: activity_tick on_activity_tick: activity_tick,
on_subscribed: on_subscribed
) do |event| ) do |event|
event_count += 1 event_count += 1
reply.apply(event) reply.apply(event)
@@ -308,6 +319,13 @@ module Opencode
rescue Opencode::SessionNotFoundError rescue Opencode::SessionNotFoundError
raise raise
rescue StandardError => e 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, Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
context: { feature: @error_feature, error_class: e.class.name }) context: { feature: @error_feature, error_class: e.class.name })
emit("stream.interrupted", emit("stream.interrupted",

View File

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

View File

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

67
test/example_test.rb Normal file
View File

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

View File

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

View File

@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
Opencode::ErrorReporter.report(RuntimeError.new("kaboom")) Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
assert invoked, "Adapter should be invoked even with no kwargs" assert invoked, "Adapter should be invoked even with no kwargs"
end 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 end

View File

@@ -0,0 +1,26 @@
# 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,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "test_helper" require "test_helper"
require "open3"
# Smoke test: every constant the gem promises is defined and points at # Smoke test: every constant the gem promises is defined and points at
# the right kind of object. If require "opencode-rails" loads cleanly, # the right kind of object. If require "opencode-rails" loads cleanly,
@@ -36,16 +37,27 @@ class Opencode::LoadingTest < Minitest::Test
end end
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 def test_session_constant_points_at_this_gem
location = Opencode::Session.instance_method(:initialize).source_location.first location = Opencode::Session.instance_method(:initialize).source_location.first
assert_match %r{/opencode-rails/}, location, expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}" assert_equal expected, File.expand_path(location),
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
end end
def test_client_constant_points_at_opencode_ruby def test_client_constant_points_at_opencode_ruby
location = Opencode::Client.instance_method(:initialize).source_location.first location = Opencode::Client.instance_method(:initialize).source_location.first
assert_match %r{/opencode-ruby/}, location, assert_match GEM_SOURCE_PATTERN.call("opencode-ruby", "client"), location,
"Expected Opencode::Client to come from opencode-ruby, got: #{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 end
def test_version_constant def test_version_constant
@@ -61,4 +73,24 @@ class Opencode::LoadingTest < Minitest::Test
refute Opencode.const_defined?(:Rails), refute Opencode.const_defined?(:Rails),
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace" "Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
end 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 end

View File

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

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

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

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

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

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

210
test/opencode/turn_test.rb Normal file
View File

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

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

82
test/readme_test.rb Normal file
View File

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

View File

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

16
test/version_test.rb Normal file
View File

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

View File

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