Compare commits
24 Commits
v0.0.1.alp
...
fix/alpha8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9937e45f5e | |||
| 427bd47648 | |||
| b02bcc74ef | |||
| a9add2a7c1 | |||
| 61beb17f55 | |||
| 8b78a558e1 | |||
| 4d975d99b3 | |||
|
|
b27d39edf4 | ||
| 75e4435d9d | |||
| 6148fc67da | |||
|
|
deeccb119e | ||
| fe953020d5 | |||
|
|
2a391ccad1 | ||
| 7744fe211a | |||
|
|
17025f0ed9 | ||
| 451ef97b9f | |||
| b0e8cf8e20 | |||
| de51ff3a45 | |||
| ca08bb36e8 | |||
| a5bd656144 | |||
| ff6187118d | |||
| 3af78b9716 | |||
| 83b0a4ee77 | |||
| 9b0c4cd3cd |
81
.github/workflows/release.yml
vendored
Normal file
81
.github/workflows/release.yml
vendored
Normal 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
37
.github/workflows/test.yml
vendored
Normal 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'
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -1,8 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.1.alpha9 - 2026-07-20
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
|
||||
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
||||
prompt and at-most-once reconnect contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep transform destination filenames out of the agent-authored identity
|
||||
attachment path and require transforms to verify trust explicitly.
|
||||
- Anchor uploads to an opened sandbox directory across path swaps, replace
|
||||
destination symlinks without following them, and validate bounded reads from
|
||||
the opened sandbox file. Upload copying now requires a traversable
|
||||
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
|
||||
- Reject hard-linked sandbox artifacts and open files with nonblocking,
|
||||
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
|
||||
closed without hanging artifact collection.
|
||||
- Make the reference integration deny every OpenCode tool by default and
|
||||
require project-local OpenCode configuration to be disabled at server
|
||||
startup. Hosts must add their own OS or container boundary before allowing
|
||||
filesystem tools.
|
||||
- Load every runtime standard library explicitly and align the shipped
|
||||
permissions, observer, Turn, prompt, and instrumentation examples with the
|
||||
actual APIs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Gate release on tests plus exact local package installation across Ruby 3.2,
|
||||
3.3, 3.4, and 4.0 in a read-only verification job.
|
||||
- Pin every third-party CI and release action to an exact reviewed commit and
|
||||
use Ruby 4.0 for release builds.
|
||||
- Fail the trusted-publishing job before release when the pushed tag does not
|
||||
match `Opencode::RAILS_VERSION`.
|
||||
- Refuse to publish Rails until the RubyGems client package exists and loads
|
||||
with the locally built Rails package in an isolated gem repository.
|
||||
|
||||
## 0.0.1.alpha8 - 2026-07-20
|
||||
|
||||
### Unpublished
|
||||
|
||||
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
|
||||
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
|
||||
tuple.
|
||||
|
||||
## 0.0.1.alpha7 - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Make `Opencode::Turn` submit `prompt_async` through the transport's
|
||||
at-most-once `on_subscribed` callback, after `server.connected` proves the
|
||||
SSE listener is ready. Reconnects reopen only SSE and never replay the user
|
||||
prompt.
|
||||
- Fail the turn directly when subscription setup or the prompt POST fails
|
||||
before a turn is confirmed started. The recovery path no longer risks
|
||||
finalizing stale exchange text after a pre-turn failure.
|
||||
- Add a gem-level behavioral regression for the cross-gem ordering contract,
|
||||
including reconnect and ambiguous prompt timeout cases.
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha7`.
|
||||
|
||||
## 0.0.1.alpha6 - 2026-07-18
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. This exposed
|
||||
subscribe-before-prompt through `Client#stream`, but the lower-level
|
||||
`Opencode::Turn` path still sent before `stream_events`; that orchestration
|
||||
gap is fixed in alpha7.
|
||||
|
||||
## 0.0.1.alpha5 - 2026-07-15
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha5`, exposing
|
||||
native parent-linked and configured session creation to Rails hosts.
|
||||
|
||||
## 0.0.1.alpha4 - 2026-07-12
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha4`, adding
|
||||
current `session.status` idle handling and correct multi-assistant tool-loop
|
||||
finalization for Rails turns.
|
||||
|
||||
## 0.0.1.alpha3 - 2026-07-10
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha3`, exposing
|
||||
the session permission PATCH primitive to Rails host applications while
|
||||
leaving reconciliation policy in each host.
|
||||
|
||||
## 0.0.1.alpha2 — 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- `Opencode::Exchange` now emits `opencode.apply_patch.artifacts_dropped`
|
||||
via the new `Opencode::Instrumentation.notify` fire-and-forget API
|
||||
(introduced in opencode-ruby v0.0.1.alpha2) instead of
|
||||
`.instrument(name, payload) { }` with an empty block. Cleaner read
|
||||
at the call site; identical semantics on the wire (same event name,
|
||||
same payload).
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha2` (was
|
||||
`= 0.0.1.alpha1`). Versions stay in lockstep during alpha.
|
||||
|
||||
## 0.0.1.alpha1 — 2026-05-20
|
||||
|
||||
Initial public alpha. Extracted from a production 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:**
|
||||
|
||||
@@ -29,6 +141,6 @@ Initial public alpha. Extracted from a production multi-product Rails app (`ajen
|
||||
**Known limitations (alpha):**
|
||||
|
||||
- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path.
|
||||
- Smoke tests only inside the gem (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 Rails Engine integration — `require "opencode-rails"` is sufficient.
|
||||
|
||||
73
CONTRIBUTING.md
Normal file
73
CONTRIBUTING.md
Normal 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
10
Gemfile
@@ -1,11 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# opencode-ruby is the underlying wire client. During alpha both gems
|
||||
# evolve in lockstep; the gemspec pins a release version, but for local
|
||||
# dev we pull from the sibling working copy so changes in opencode-ruby
|
||||
# are picked up without a release cycle.
|
||||
if File.exist?(File.expand_path("../opencode-ruby", __dir__))
|
||||
gem "opencode-ruby", path: File.expand_path("../opencode-ruby", __dir__)
|
||||
end
|
||||
gem "opencode-ruby",
|
||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||
|
||||
gemspec
|
||||
|
||||
135
README.md
135
README.md
@@ -1,6 +1,6 @@
|
||||
# opencode-rails
|
||||
|
||||
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://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.
|
||||
|
||||
@@ -17,17 +17,55 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
|
||||
|
||||
## Install
|
||||
|
||||
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "opencode-ruby" # wire client + Reply state machine
|
||||
gem "opencode-rails" # AR-coupled session/turn/artifact stack
|
||||
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
|
||||
gem "opencode-rails", "= 0.0.1.alpha9"
|
||||
```
|
||||
|
||||
Until publication is verified, validate this candidate checkout against the
|
||||
exact `opencode-ruby` source it was tested with:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "opencode-ruby",
|
||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||
|
||||
gem "opencode-rails",
|
||||
path: "../opencode-rails"
|
||||
```
|
||||
|
||||
```bash
|
||||
bundle install
|
||||
```
|
||||
|
||||
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
|
||||
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
|
||||
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
|
||||
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
# app/jobs/generate_response_job.rb
|
||||
class GenerateResponseJob < ApplicationJob
|
||||
def perform(assistant_message)
|
||||
def perform(assistant_message, user_message)
|
||||
conversation = assistant_message.conversation
|
||||
user_message = conversation.messages.where(role: :user).last
|
||||
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"])
|
||||
unless user_message.conversation_id == conversation.id
|
||||
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
||||
end
|
||||
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
|
||||
client = Opencode::Client.new(
|
||||
base_url: ENV.fetch("OPENCODE_URL"),
|
||||
directory: working_directory
|
||||
)
|
||||
|
||||
session = Opencode::Session.new(
|
||||
conversation,
|
||||
@@ -60,9 +120,15 @@ class GenerateResponseJob < ApplicationJob
|
||||
subject: conversation,
|
||||
query_text: user_message.content,
|
||||
client: client,
|
||||
session: session,
|
||||
session_for: session,
|
||||
observer_factory: ->(_message) { NoopReplyObserver.new },
|
||||
system_context: ->(record) { "You are assisting with #{record.title}." },
|
||||
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
|
||||
tracer: ->(name, **payload) {
|
||||
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
|
||||
},
|
||||
on_turn_finished: ->(result) {
|
||||
# result.status #=> :completed | :error | :cancelled
|
||||
# result.status #=> :completed | :cancelled | :error | :failed
|
||||
# result.message #=> the AR row (reloaded)
|
||||
# result.duration_ms
|
||||
}
|
||||
@@ -71,15 +137,41 @@ class GenerateResponseJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def permission_rules_for(conversation)
|
||||
def permission_rules_for(_conversation)
|
||||
[
|
||||
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }
|
||||
{ permission: "*", pattern: "*", action: "deny" }
|
||||
]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`.
|
||||
Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
|
||||
project configuration, plugins, and instructions while opening a directory,
|
||||
before session permissions exist; the wildcard deny rule cannot constrain that
|
||||
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
|
||||
mounted at the same absolute path in Rails and OpenCode. Treat the server's
|
||||
global configuration, plugins, and instructions as privileged administrator
|
||||
code and verify the setting against the deployed OpenCode version.
|
||||
|
||||
This quickstart intentionally grants no filesystem tools: OpenCode permissions
|
||||
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
|
||||
process also has an independent container or operating-system boundary.
|
||||
|
||||
`Opencode::Session` applies permissions only when it creates a session. Before
|
||||
deploying a directory or permission-policy change, recreate persisted sessions
|
||||
with a client scoped to the new directory; an existing session ID does not
|
||||
prove that the new policy was applied.
|
||||
|
||||
The host record (here `conversation`) must respond to `#title`,
|
||||
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
|
||||
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
|
||||
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
|
||||
`#error!(content)`. A non-no-op observer may impose additional record methods
|
||||
for its own live snapshots.
|
||||
|
||||
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
|
||||
constructor is intentionally explicit and may change before 1.0, so keep this
|
||||
wiring in one host service/job and keep the gem source pinned exactly.
|
||||
|
||||
## What you get
|
||||
|
||||
@@ -102,17 +194,18 @@ Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Trace
|
||||
|
||||
## Instrumentation + error reporting
|
||||
|
||||
The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above).
|
||||
The wire client emits `opencode.request` and artifact extraction emits
|
||||
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
|
||||
Turn events flow through the injected tracer; with the Quickstart's
|
||||
`assistant.` prefix they include:
|
||||
|
||||
Events emitted (non-exhaustive):
|
||||
- `assistant.response.started`, `assistant.turn.finished`
|
||||
- `assistant.stream.completed`, `assistant.stream.interrupted`
|
||||
- `assistant.session.created`, `assistant.session.recreated_with_resend`
|
||||
- `assistant.response.upstream_error`
|
||||
|
||||
- `opencode.turn.started`, `opencode.turn.finished`
|
||||
- `opencode.stream.completed`, `opencode.stream.interrupted`
|
||||
- `opencode.session.created`, `opencode.session.recreated`
|
||||
- `opencode.apply_patch.artifacts_dropped`
|
||||
- `opencode.response.upstream_error`
|
||||
|
||||
Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter.
|
||||
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
|
||||
Instrumentation and error reporting are no-ops until the host wires adapters.
|
||||
|
||||
## Position
|
||||
|
||||
|
||||
217
examples/rails_integration.rb
Normal file
217
examples/rails_integration.rb
Normal 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.
|
||||
@@ -9,6 +9,12 @@
|
||||
# rename work.
|
||||
|
||||
require "opencode-ruby"
|
||||
require "fileutils"
|
||||
require "marcel"
|
||||
require "pathname"
|
||||
require "set"
|
||||
require "stringio"
|
||||
require "tempfile"
|
||||
|
||||
require "active_support/core_ext/object/blank" # blank?, present?, presence
|
||||
require "active_support/core_ext/object/try"
|
||||
@@ -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/numeric/time" # 2.seconds, 5.minutes, etc.
|
||||
|
||||
require_relative "opencode/rails/version"
|
||||
require_relative "opencode/rails_version"
|
||||
require_relative "opencode/error_reporter"
|
||||
|
||||
# Tier 4 leaves (no deps on other rails-gem files)
|
||||
|
||||
@@ -9,10 +9,10 @@ module Opencode
|
||||
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
|
||||
# call's input/metadata (write tool).
|
||||
# - Opencode::SandboxFile#as_artifact — identity conversion of a
|
||||
# sandbox-resident file (the default path for Blackline + Raven).
|
||||
# sandbox-resident file (the default identity path).
|
||||
#
|
||||
# Transforms also return Artifacts; that's why FlightResultsTransform
|
||||
# returns one with the host-rendered HTML + trust metadata stamp.
|
||||
# Transforms also return Artifacts — e.g. a host-rendered HTML
|
||||
# artifact carrying a trust-metadata stamp.
|
||||
#
|
||||
# An Artifact knows how to attach itself to a message, idempotently:
|
||||
# it consults `message.artifacts` to skip if its filename is already
|
||||
|
||||
@@ -65,13 +65,13 @@ module Opencode
|
||||
eligible = file_entries.reject { |e| e[:type] == "delete" }
|
||||
next if eligible.empty?
|
||||
|
||||
Opencode::Instrumentation.instrument("opencode.apply_patch.artifacts_dropped",
|
||||
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
|
||||
file_count: eligible.size,
|
||||
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
|
||||
message_id: part[:messageID],
|
||||
session_id: part[:sessionID],
|
||||
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
|
||||
"extraction requires sandbox-read which is not yet wired into ResponseParser") { }
|
||||
"extraction requires sandbox-read which is not yet wired into ResponseParser")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,9 @@ module Opencode
|
||||
# 1. A previous job retry attached the destination filename via the
|
||||
# tool-extracted path (the agent wrote a file with that name and
|
||||
# it landed before the trusted render did).
|
||||
# 2. A pre-substrate code path persisted an agent-authored HTML file
|
||||
# with the destination filename (the historical AIGL exploit
|
||||
# surface that motivated the trust boundary in the first place).
|
||||
# 2. A pre-substrate code path persisted an agent-authored file
|
||||
# under the destination filename — the same-name stored-XSS
|
||||
# attack the trust boundary exists to prevent.
|
||||
# 3. A previous transform version stamped different metadata and the
|
||||
# trust check now correctly rejects it.
|
||||
#
|
||||
|
||||
@@ -9,7 +9,7 @@ module Opencode
|
||||
#
|
||||
# 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)
|
||||
#
|
||||
# All four phases (tool extract, transform routing, impostor purge,
|
||||
@@ -24,15 +24,14 @@ module Opencode
|
||||
MAX_SANDBOX_ARTIFACTS = 20
|
||||
|
||||
# default_attach values:
|
||||
# :all — Blackline/Raven default. Every safe sandbox file that
|
||||
# no transform claims falls through to identity attach.
|
||||
# The agent's `write` outputs are final document bytes the
|
||||
# host serves back unchanged.
|
||||
# :none — AIGL. The agent's sandbox is full of internal working
|
||||
# scratch (notes.md, map.md, timeline.md) plus the one
|
||||
# file the transform claims (flight-results.json). Only
|
||||
# transform-claimed files attach; everything else stays
|
||||
# agent-internal.
|
||||
# :all — every safe sandbox file that no transform claims falls
|
||||
# through to identity attach. Use when the agent's `write`
|
||||
# outputs are final document bytes the host serves back
|
||||
# unchanged.
|
||||
# :none — only transform-claimed files attach; everything else stays
|
||||
# agent-internal. Use when the agent's sandbox is full of
|
||||
# working scratch the user shouldn't see, and only specific
|
||||
# filenames (claimed by transforms) become artifacts.
|
||||
def initialize(message:, feature:, transforms: [], default_attach: :all,
|
||||
max_sandbox_files: MAX_SANDBOX_ARTIFACTS)
|
||||
@message = message
|
||||
@@ -81,12 +80,15 @@ module Opencode
|
||||
|
||||
if (transform = transforms.find { |t| t.applies_to?(file) })
|
||||
attached += 1 if apply_transform(transform, file)
|
||||
elsif transform_owned_filenames.include?(file.basename)
|
||||
# Destination names belong exclusively to their host transform.
|
||||
# Agent-authored bytes must never fall through as trusted output.
|
||||
next
|
||||
elsif default_attach == :all
|
||||
# Default identity path. Blackline/Raven default — every safe
|
||||
# sandbox file that no transform claims attaches as-is. AIGL
|
||||
# passes default_attach: :none so non-transform files (the
|
||||
# agent's notes.md / map.md / timeline.md scratch) don't
|
||||
# auto-attach.
|
||||
# Default identity path: every safe sandbox file that no
|
||||
# transform claims attaches as-is. Callers that want the
|
||||
# opposite (only transform-claimed files attach) construct
|
||||
# MessageArtifacts with default_attach: :none.
|
||||
attached += 1 if file.as_artifact.attach_to(message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
# We can't reuse the same constant from a second gem, so we use a
|
||||
# distinct, non-namespaced constant.
|
||||
module Opencode
|
||||
RAILS_VERSION = "0.0.1.alpha1"
|
||||
RAILS_VERSION = "0.0.1.alpha9"
|
||||
end
|
||||
@@ -32,9 +32,9 @@ module Opencode
|
||||
|
||||
# Yields SandboxFile values for every file in the sandbox that
|
||||
# passes its own #safe? predicate AND was modified after the cutoff.
|
||||
# When `after:` is nil (callers without a user_message handle, e.g.
|
||||
# AIGL on certain finalize paths), no mtime filter is applied —
|
||||
# only safety + filetype.
|
||||
# When `after:` is nil (callers without a user_message handle —
|
||||
# e.g. finalize paths that scan the whole sandbox), no mtime filter
|
||||
# is applied — only safety + filetype.
|
||||
def files(after: nil)
|
||||
return enum_for(:files, after: after) unless block_given?
|
||||
return unless exists?
|
||||
|
||||
@@ -15,6 +15,8 @@ module Opencode
|
||||
# not here — the file doesn't know which turn opened "after." That's
|
||||
# a property of the scan, not a property of the file.
|
||||
class SandboxFile
|
||||
UnsafeFileError = Class.new(Opencode::Error)
|
||||
|
||||
attr_reader :path, :sandbox_prefix
|
||||
|
||||
def initialize(path:, sandbox_prefix:, max_bytes:)
|
||||
@@ -36,7 +38,13 @@ module Opencode
|
||||
end
|
||||
|
||||
def content
|
||||
File.read(path)
|
||||
with_safe_file do |file|
|
||||
content = file.read(@max_bytes + 1) || "".b
|
||||
if content.bytesize > @max_bytes
|
||||
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
|
||||
end
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def content_type
|
||||
@@ -52,24 +60,18 @@ module Opencode
|
||||
# - Reject anything over the size cap (default
|
||||
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
|
||||
#
|
||||
# The Sandbox scan filters non-files (directories, FIFOs) before
|
||||
# yielding, so we don't re-check #file? here.
|
||||
# Revalidates the opened file descriptor so a path swap between the
|
||||
# sandbox scan and the read cannot redirect content outside the sandbox.
|
||||
def safe?
|
||||
return false if File.symlink?(path)
|
||||
return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix)
|
||||
return false if size > @max_bytes
|
||||
|
||||
true
|
||||
rescue Errno::ENOENT
|
||||
# Concurrent deletion between scan-yield and safety-check — treat
|
||||
# as unsafe so the orchestrator skips rather than crashing.
|
||||
with_safe_file { true }
|
||||
rescue UnsafeFileError
|
||||
false
|
||||
end
|
||||
|
||||
# Identity conversion: this sandbox file → an Artifact carrying the
|
||||
# file's own bytes. Used by the substrate's default (non-transform)
|
||||
# path for Blackline + Raven, whose agents write document bytes
|
||||
# directly to the sandbox and expect them attached unchanged.
|
||||
# path, where the agent writes document bytes directly to the
|
||||
# sandbox and the host serves them back unchanged.
|
||||
def as_artifact
|
||||
Artifact.new(
|
||||
filename: basename,
|
||||
@@ -77,5 +79,46 @@ module Opencode
|
||||
content_type: content_type
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_safe_file
|
||||
before = File.lstat(path)
|
||||
unless before.file? && !before.symlink? && before.nlink == 1
|
||||
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
|
||||
end
|
||||
|
||||
resolved = Pathname.new(path).realpath.to_s
|
||||
unless resolved.start_with?(sandbox_prefix)
|
||||
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
|
||||
end
|
||||
|
||||
flags = safe_open_flags
|
||||
File.open(path, flags, encoding: Encoding::BINARY) do |file|
|
||||
opened = file.stat
|
||||
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
|
||||
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
|
||||
end
|
||||
if opened.size > @max_bytes
|
||||
raise UnsafeFileError, "Sandbox file exceeds size limit: #{basename}"
|
||||
end
|
||||
|
||||
yield file
|
||||
end
|
||||
rescue SystemCallError => e
|
||||
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
|
||||
end
|
||||
|
||||
def safe_open_flags
|
||||
required = %i[NONBLOCK NOFOLLOW]
|
||||
missing = required.reject { |name| File.const_defined?(name) }
|
||||
unless missing.empty?
|
||||
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
|
||||
end
|
||||
|
||||
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
|
||||
flags |= File::BINARY if File.const_defined?(:BINARY)
|
||||
flags
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
module Opencode
|
||||
# Owns the lifecycle of an OpenCode session against a domain record.
|
||||
#
|
||||
# Three near-identical implementations of this lifecycle existed on
|
||||
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
|
||||
# had subtle differences (Blackline and Raven didn't take a row-level
|
||||
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
|
||||
# architectural review — a change to the lifecycle had to be made in
|
||||
# three places that looked alike but disagreed on locking. This PORO
|
||||
# is the consolidated role.
|
||||
# Consolidates a lifecycle that's easy to get subtly wrong: a host
|
||||
# with N conversational products tends to grow N near-identical
|
||||
# session-management code paths that drift on the details (which
|
||||
# one took a row-level lock? which one swallowed teardown errors?).
|
||||
# Single PORO, one place to change, no shotgun surgery when the
|
||||
# protocol evolves.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
@@ -23,11 +22,10 @@ module Opencode
|
||||
# service.abort!(client) # best-effort upstream abort
|
||||
#
|
||||
# The permissions_for: callable receives the record at mint! time and
|
||||
# returns the permissions array for client.create_session. Product-
|
||||
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
|
||||
# returns the permissions array for client.create_session. Per-product
|
||||
# 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
|
||||
# of any reference to permission-building helpers and preserves the
|
||||
# rails-tier -> containers-tier boundary the design doc locks in.
|
||||
# of any reference to permission-building helpers.
|
||||
#
|
||||
# The on_error: callable is invoked when abort! catches an
|
||||
# Opencode::Error during teardown. Callers wire their own observability
|
||||
|
||||
@@ -9,8 +9,8 @@ module Opencode
|
||||
# icon identifier.
|
||||
#
|
||||
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
|
||||
# so Blackline views, AIGL views, and any future OpenCode-backed
|
||||
# feature can render tool calls consistently.
|
||||
# so any view that renders OpenCode tool calls — across whatever
|
||||
# products the host runs — can do so consistently.
|
||||
#
|
||||
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
||||
#
|
||||
|
||||
@@ -6,13 +6,16 @@ module Opencode
|
||||
# agent wrote" and "bytes the host signs and attaches."
|
||||
#
|
||||
# The default substrate path is identity: any sandbox file the
|
||||
# allowlist accepts gets attached as-is. Blackline and Raven use
|
||||
# the default — their agents `write` final document bytes the host
|
||||
# serves back unchanged. AIGL's contract is structurally different:
|
||||
# the agent writes JSON, the **host** must render that JSON into
|
||||
# trusted HTML before attaching, because the resulting HTML gets
|
||||
# served inline from the app origin and an agent-written filename
|
||||
# can't be permitted as stored-XSS.
|
||||
# allowlist accepts gets attached as-is. This works when the agent
|
||||
# writes the final document bytes itself and the host just serves
|
||||
# them back unchanged.
|
||||
#
|
||||
# Subclass Transform when the contract is structurally different:
|
||||
# the agent writes raw data (e.g. JSON), and the **host** must
|
||||
# render that data into trusted output (e.g. HTML) before attaching.
|
||||
# The split matters when the resulting bytes get served inline from
|
||||
# your app origin — an agent-written filename can't be permitted as
|
||||
# stored-XSS, so a Transform draws the trust boundary.
|
||||
#
|
||||
# Subclass hooks (override these — none have a generic default
|
||||
# that's safe to inherit):
|
||||
@@ -28,7 +31,8 @@ module Opencode
|
||||
# trusted?(attachment) — true if the attachment was produced by
|
||||
# this transform (used by Impostor.for and
|
||||
# by view code that decides inline-render
|
||||
# vs download). Default: filename match.
|
||||
# vs download). Default: false; subclasses
|
||||
# must verify host-authored metadata.
|
||||
# purge_impostors? — if true, before attaching the substrate
|
||||
# deletes any existing attachment whose
|
||||
# filename matches destination_filename
|
||||
@@ -57,8 +61,8 @@ module Opencode
|
||||
raise NotImplementedError, "#{self.class.name} must implement #render"
|
||||
end
|
||||
|
||||
def trusted?(attachment)
|
||||
attachment.filename.to_s == destination_filename
|
||||
def trusted?(_attachment)
|
||||
false
|
||||
end
|
||||
|
||||
def purge_impostors?
|
||||
|
||||
@@ -36,8 +36,8 @@ module Opencode
|
||||
# smallest honest shape.
|
||||
#
|
||||
# Composition over inheritance: every product-specific concern is a
|
||||
# collaborator passed in. Turn never sees Blackline, Raven, or AIGL by
|
||||
# name.
|
||||
# collaborator passed in. Turn never sees any specific product by
|
||||
# name — the orchestration shape is uniform.
|
||||
#
|
||||
# Collaborators
|
||||
# -------------
|
||||
@@ -50,7 +50,7 @@ module Opencode
|
||||
#
|
||||
# observer_factory callable: ->(message) returning an observer that
|
||||
# responds to #watch(reply). Concretely:
|
||||
# ->(message) { Blackline::ReplyStream.new(...) }.
|
||||
# ->(message) { MyApp::ReplyStream.new(message: message) }.
|
||||
#
|
||||
# system_context callable: ->(subject) -> String system prompt.
|
||||
#
|
||||
@@ -213,12 +213,6 @@ module Opencode
|
||||
emit_session_created_if_new
|
||||
validate_session!(session_id)
|
||||
|
||||
@client.send_message_async(
|
||||
session_id, @query_text,
|
||||
agent: @agent_name.call(@subject),
|
||||
system: @system_context.call(@subject)
|
||||
)
|
||||
|
||||
stream_result = stream_response(session_id)
|
||||
exchange = fetch_current_exchange(session_id)
|
||||
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
|
||||
@@ -260,6 +254,22 @@ module Opencode
|
||||
last_activity_touch_at = stream_started_at
|
||||
first_token_at = nil
|
||||
event_count = 0
|
||||
prompt_attempted = false
|
||||
prompt_succeeded = false
|
||||
on_subscribed = lambda do
|
||||
# stream_events guarantees at-most-once invocation, but keep this
|
||||
# guard here as a second line of defense because an ambiguous prompt
|
||||
# response must never become a duplicate model turn.
|
||||
next false if prompt_attempted
|
||||
|
||||
prompt_attempted = true
|
||||
@client.send_message_async(
|
||||
session_id, @query_text,
|
||||
agent: @agent_name.call(@subject),
|
||||
system: @system_context.call(@subject)
|
||||
)
|
||||
prompt_succeeded = true
|
||||
end
|
||||
|
||||
begin
|
||||
release_active_record_connections
|
||||
@@ -293,7 +303,8 @@ module Opencode
|
||||
@client.stream_events(
|
||||
session_id: session_id,
|
||||
reply: reply,
|
||||
on_activity_tick: activity_tick
|
||||
on_activity_tick: activity_tick,
|
||||
on_subscribed: on_subscribed
|
||||
) do |event|
|
||||
event_count += 1
|
||||
reply.apply(event)
|
||||
@@ -308,6 +319,13 @@ module Opencode
|
||||
rescue Opencode::SessionNotFoundError
|
||||
raise
|
||||
rescue StandardError => e
|
||||
# Subscription rejection or prompt transport failure happened before
|
||||
# a turn was confirmed started. Recovering from the pre-turn exchange
|
||||
# could finalize stale text, and retrying an ambiguous POST could
|
||||
# duplicate spend, so surface the original failure to the outer error
|
||||
# path without reconnect/recovery.
|
||||
raise unless prompt_succeeded
|
||||
|
||||
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
|
||||
context: { feature: @error_feature, error_class: e.class.name })
|
||||
emit("stream.interrupted",
|
||||
|
||||
@@ -24,8 +24,10 @@ module Opencode
|
||||
# Side effect, unchanged from the concern: file bytes are copied from
|
||||
# ActiveStorage into the per-user OpenCode sandbox directory so the
|
||||
# agent can read them with the `read` tool. The copy is path-escape
|
||||
# guarded (the cleanpath of the destination must start with the
|
||||
# sandbox dir prefix, no symlink trickery).
|
||||
# guarded: generated names must be basenames, and all writes stay anchored
|
||||
# to an opened directory handle even if the sandbox path is replaced.
|
||||
# A temporary file is renamed into place so destination symlinks are
|
||||
# replaced rather than followed. Retried jobs can refresh existing copies.
|
||||
class UploadedFilesPrompt
|
||||
attr_reader :text, :sandbox_file_names
|
||||
|
||||
@@ -52,24 +54,77 @@ module Opencode
|
||||
[
|
||||
raw,
|
||||
"",
|
||||
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
|
||||
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly before responding:",
|
||||
*file_instructions
|
||||
].join("\n").strip
|
||||
end
|
||||
|
||||
def copy_to_sandbox(file)
|
||||
FileUtils.mkdir_p(@sandbox_path)
|
||||
sandbox_path = Pathname.new(@sandbox_path).expand_path
|
||||
FileUtils.mkdir_p(sandbox_path)
|
||||
sandbox_stat = File.lstat(sandbox_path)
|
||||
unless sandbox_stat.directory? && !sandbox_stat.symlink?
|
||||
raise ArgumentError, "Sandbox root must be a directory, not a symlink: #{sandbox_path}"
|
||||
end
|
||||
|
||||
sandbox_name = @sandbox_name_for.call(file)
|
||||
dest = File.join(@sandbox_path, sandbox_name)
|
||||
|
||||
resolved = Pathname.new(dest).cleanpath.to_s
|
||||
unless resolved.start_with?(@sandbox_path)
|
||||
sandbox_name = @sandbox_name_for.call(file).to_s
|
||||
unless sandbox_name == File.basename(sandbox_name) && !%w[. ..].include?(sandbox_name)
|
||||
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
|
||||
end
|
||||
|
||||
File.open(dest, "wb") { |f| f.write(file.download) }
|
||||
Placement.new(sandbox_name, dest)
|
||||
File.open(sandbox_path, File::RDONLY) do |directory|
|
||||
opened_stat = directory.stat
|
||||
unless opened_stat.directory? && same_file?(sandbox_stat, opened_stat)
|
||||
raise ArgumentError, "Sandbox root changed while opening: #{sandbox_path}"
|
||||
end
|
||||
|
||||
directory_path = directory_handle_path(directory)
|
||||
dest = File.join(directory_path, sandbox_name)
|
||||
mode = destination_mode(dest)
|
||||
|
||||
Tempfile.create([ ".opencode-upload-", ".tmp" ], directory_path) do |temp|
|
||||
temp.binmode
|
||||
temp.write(file.download)
|
||||
temp.flush
|
||||
temp.chmod(mode)
|
||||
File.rename(temp.path, dest)
|
||||
|
||||
unless same_file_at_path?(sandbox_path, opened_stat)
|
||||
File.unlink(dest)
|
||||
raise ArgumentError, "Sandbox root changed during upload: #{sandbox_path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
Placement.new(sandbox_name, sandbox_path.join(sandbox_name).to_s)
|
||||
end
|
||||
|
||||
def directory_handle_path(directory)
|
||||
stat = directory.stat
|
||||
[ "/proc/self/fd/#{directory.fileno}", "/dev/fd/#{directory.fileno}" ].find do |path|
|
||||
same_file?(File.stat(path), stat)
|
||||
rescue Errno::ENOENT
|
||||
false
|
||||
end || raise(ArgumentError, "Platform cannot anchor writes to the opened sandbox directory")
|
||||
end
|
||||
|
||||
def destination_mode(path)
|
||||
stat = File.lstat(path)
|
||||
return stat.mode & 0o777 if stat.file? && !stat.symlink?
|
||||
|
||||
0o666 & ~File.umask
|
||||
rescue Errno::ENOENT
|
||||
0o666 & ~File.umask
|
||||
end
|
||||
|
||||
def same_file_at_path?(path, expected)
|
||||
current = File.lstat(path)
|
||||
current.directory? == expected.directory? && !current.symlink? && same_file?(current, expected)
|
||||
rescue Errno::ENOENT
|
||||
false
|
||||
end
|
||||
|
||||
def same_file?(left, right)
|
||||
left.dev == right.dev && left.ino == right.ino
|
||||
end
|
||||
|
||||
# Tiny value pair returned by copy_to_sandbox: the canonical filename
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "lib/opencode/rails/version"
|
||||
require_relative "lib/opencode/rails_version"
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "opencode-rails"
|
||||
spec.version = Opencode::RAILS_VERSION
|
||||
spec.authors = ["Ajay Krishnan"]
|
||||
spec.email = ["ajay@krishnan.ca"]
|
||||
spec.email = ["opencode-rails@ajay.to"]
|
||||
|
||||
spec.summary = "Production-grade Rails integration for OpenCode."
|
||||
spec.description = <<~DESC
|
||||
@@ -19,22 +19,26 @@ Gem::Specification.new do |spec|
|
||||
production-grade OpenCode streaming without rolling your own
|
||||
boilerplate.
|
||||
DESC
|
||||
spec.homepage = "https://gitea.krishnan.ca/ajaynomics/opencode-rails"
|
||||
spec.homepage = "https://github.com/ajaynomics/opencode-rails"
|
||||
spec.license = "MIT"
|
||||
spec.required_ruby_version = ">= 3.2.0"
|
||||
|
||||
spec.metadata["homepage_uri"] = spec.homepage
|
||||
spec.metadata["source_code_uri"] = spec.homepage
|
||||
spec.metadata["changelog_uri"] = "#{spec.homepage}/src/branch/main/CHANGELOG.md"
|
||||
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
||||
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
||||
|
||||
spec.files = Dir.glob("lib/**/*.rb") +
|
||||
Dir.glob("examples/**/*.rb") +
|
||||
%w[README.md LICENSE CHANGELOG.md opencode-rails.gemspec]
|
||||
spec.require_paths = ["lib"]
|
||||
|
||||
# The opencode-ruby gem provides the wire-level Client + Reply primitives
|
||||
# this gem builds on. Versions are kept in lockstep during the alpha
|
||||
# phase; will relax to a looser pessimistic pin once both gems stabilize.
|
||||
spec.add_runtime_dependency "opencode-ruby", "~> 0.0.1.alpha1"
|
||||
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
||||
# exactly (= not ~>) so that consumers always pick the version this gem
|
||||
# was tested against.
|
||||
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
|
||||
spec.add_runtime_dependency "marcel", "~> 1.0"
|
||||
|
||||
# Rails sub-libraries used at runtime. Depending on these individually
|
||||
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
||||
|
||||
67
test/example_test.rb
Normal file
67
test/example_test.rb
Normal 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
|
||||
@@ -4,8 +4,8 @@ require "test_helper"
|
||||
|
||||
# Smoke test for Opencode::Artifact — verifies the value-object surface
|
||||
# (filename/content/content_type readers). Behavioral tests around how
|
||||
# the host's ActiveStorage-backed AIGL::Trip/etc. records build artifact
|
||||
# collections live in the host's test suite.
|
||||
# the host's ActiveStorage-backed records build artifact collections
|
||||
# live in the host's test suite.
|
||||
class Opencode::ArtifactTest < Minitest::Test
|
||||
def test_value_object_readers
|
||||
artifact = Opencode::Artifact.new(
|
||||
|
||||
@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
|
||||
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
||||
assert invoked, "Adapter should be invoked even with no kwargs"
|
||||
end
|
||||
|
||||
def test_adapter_exceptions_propagate
|
||||
# If the host's adapter itself raises (Honeybadger HTTP failure,
|
||||
# Sentry quota error, etc.) the gem must propagate — silently
|
||||
# swallowing the adapter's own errors would hide an outage from
|
||||
# operators who think their error tracker is healthy.
|
||||
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
|
||||
raise StandardError, "adapter blew up"
|
||||
}
|
||||
|
||||
raised = assert_raises(StandardError) do
|
||||
Opencode::ErrorReporter.report(RuntimeError.new("original"))
|
||||
end
|
||||
assert_equal "adapter blew up", raised.message
|
||||
end
|
||||
|
||||
def test_report_returns_adapter_return_value
|
||||
# Useful for hosts wanting Rails.error.report's standard return
|
||||
# (the error itself). Verifies the call shape doesn't transform it.
|
||||
sentinel = Object.new
|
||||
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
|
||||
|
||||
result = Opencode::ErrorReporter.report(StandardError.new("x"))
|
||||
assert_same sentinel, result
|
||||
end
|
||||
end
|
||||
|
||||
26
test/opencode/impostor_test.rb
Normal file
26
test/opencode/impostor_test.rb
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "open3"
|
||||
|
||||
# Smoke test: every constant the gem promises is defined and points at
|
||||
# the right kind of object. If require "opencode-rails" loads cleanly,
|
||||
@@ -36,16 +37,27 @@ class Opencode::LoadingTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
# Match the gem's own lib path, not merely any parent directory. GitHub's
|
||||
# checkout layout nests Bundler under /opencode-rails/opencode-rails, so a
|
||||
# broad directory-name assertion falsely classifies a bundled
|
||||
# opencode-ruby source path as belonging to this gem.
|
||||
GEM_SOURCE_PATTERN = lambda do |name, file|
|
||||
%r{/#{Regexp.escape(name)}(?:-[^/]+)?/lib/opencode/#{Regexp.escape(file)}\.rb\z}
|
||||
end
|
||||
|
||||
def test_session_constant_points_at_this_gem
|
||||
location = Opencode::Session.instance_method(:initialize).source_location.first
|
||||
assert_match %r{/opencode-rails/}, location,
|
||||
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
|
||||
expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
|
||||
assert_equal expected, File.expand_path(location),
|
||||
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
|
||||
end
|
||||
|
||||
def test_client_constant_points_at_opencode_ruby
|
||||
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}"
|
||||
refute_match GEM_SOURCE_PATTERN.call("opencode-rails", "client"), location,
|
||||
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
|
||||
end
|
||||
|
||||
def test_version_constant
|
||||
@@ -61,4 +73,24 @@ class Opencode::LoadingTest < Minitest::Test
|
||||
refute Opencode.const_defined?(:Rails),
|
||||
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
|
||||
end
|
||||
|
||||
def test_umbrella_require_loads_runtime_standard_libraries
|
||||
root = File.expand_path("../..", __dir__)
|
||||
script = <<~'RUBY'
|
||||
require "opencode-rails"
|
||||
abort "StringIO missing" unless defined?(StringIO)
|
||||
abort "Marcel missing" unless defined?(Marcel::MimeType)
|
||||
abort "FileUtils missing" unless defined?(FileUtils)
|
||||
abort "Tempfile missing" unless defined?(Tempfile)
|
||||
RUBY
|
||||
|
||||
_stdout, stderr, status = Open3.capture3(
|
||||
Gem.ruby,
|
||||
"-I#{File.join(root, "lib")}",
|
||||
"-e",
|
||||
script
|
||||
)
|
||||
|
||||
assert status.success?, stderr
|
||||
end
|
||||
end
|
||||
|
||||
56
test/opencode/message_artifacts_test.rb
Normal file
56
test/opencode/message_artifacts_test.rb
Normal 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
|
||||
208
test/opencode/sandbox_file_test.rb
Normal file
208
test/opencode/sandbox_file_test.rb
Normal 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
|
||||
53
test/opencode/sandbox_test.rb
Normal file
53
test/opencode/sandbox_test.rb
Normal 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
|
||||
27
test/opencode/session_test.rb
Normal file
27
test/opencode/session_test.rb
Normal 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
|
||||
55
test/opencode/tool_display_test.rb
Normal file
55
test/opencode/tool_display_test.rb
Normal 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
|
||||
67
test/opencode/transform_test.rb
Normal file
67
test/opencode/transform_test.rb
Normal 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
210
test/opencode/turn_test.rb
Normal 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
|
||||
188
test/opencode/uploaded_files_prompt_test.rb
Normal file
188
test/opencode/uploaded_files_prompt_test.rb
Normal 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
82
test/readme_test.rb
Normal 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
|
||||
82
test/release_workflow_test.rb
Normal file
82
test/release_workflow_test.rb
Normal 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
16
test/version_test.rb
Normal 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
|
||||
67
test/workflow_contract_test.rb
Normal file
67
test/workflow_contract_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user