Compare commits
21 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 |
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
|
||||||
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
ruby: ["3.2", "3.3", "3.4"]
|
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Set up Ruby ${{ matrix.ruby }}
|
- name: Set up Ruby ${{ matrix.ruby }}
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ matrix.ruby }}
|
ruby-version: ${{ matrix.ruby }}
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -29,11 +29,9 @@ jobs:
|
|||||||
run: gem build opencode-rails.gemspec
|
run: gem build opencode-rails.gemspec
|
||||||
|
|
||||||
- name: Verify gem loads after install
|
- name: Verify gem loads after install
|
||||||
# opencode-rails depends on opencode-ruby; until both gems
|
|
||||||
# are on rubygems.org, the install step here will only resolve
|
|
||||||
# if opencode-ruby has been pre-installed or is reachable.
|
|
||||||
# When the gems do publish, the runtime_dependency on
|
|
||||||
# opencode-ruby will Just Work via rubygems.
|
|
||||||
run: |
|
run: |
|
||||||
gem install --local opencode-rails-*.gem --conservative
|
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'
|
ruby -ropencode-rails -e 'puts Opencode::RAILS_VERSION'
|
||||||
|
|||||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,85 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.1.alpha9 - 2026-07-20
|
||||||
|
|
||||||
|
### Bumped
|
||||||
|
|
||||||
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
|
||||||
|
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
||||||
|
prompt and at-most-once reconnect contract.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Keep transform destination filenames out of the agent-authored identity
|
||||||
|
attachment path and require transforms to verify trust explicitly.
|
||||||
|
- Anchor uploads to an opened sandbox directory across path swaps, replace
|
||||||
|
destination symlinks without following them, and validate bounded reads from
|
||||||
|
the opened sandbox file. Upload copying now requires a traversable
|
||||||
|
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
|
||||||
|
- Reject hard-linked sandbox artifacts and open files with nonblocking,
|
||||||
|
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
|
||||||
|
closed without hanging artifact collection.
|
||||||
|
- Make the reference integration deny every OpenCode tool by default and
|
||||||
|
require project-local OpenCode configuration to be disabled at server
|
||||||
|
startup. Hosts must add their own OS or container boundary before allowing
|
||||||
|
filesystem tools.
|
||||||
|
- Load every runtime standard library explicitly and align the shipped
|
||||||
|
permissions, observer, Turn, prompt, and instrumentation examples with the
|
||||||
|
actual APIs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Gate release on tests plus exact local package installation across Ruby 3.2,
|
||||||
|
3.3, 3.4, and 4.0 in a read-only verification job.
|
||||||
|
- Pin every third-party CI and release action to an exact reviewed commit and
|
||||||
|
use Ruby 4.0 for release builds.
|
||||||
|
- Fail the trusted-publishing job before release when the pushed tag does not
|
||||||
|
match `Opencode::RAILS_VERSION`.
|
||||||
|
- Refuse to publish Rails until the RubyGems client package exists and loads
|
||||||
|
with the locally built Rails package in an isolated gem repository.
|
||||||
|
|
||||||
|
## 0.0.1.alpha8 - 2026-07-20
|
||||||
|
|
||||||
|
### Unpublished
|
||||||
|
|
||||||
|
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
|
||||||
|
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
|
||||||
|
tuple.
|
||||||
|
|
||||||
|
## 0.0.1.alpha7 - 2026-07-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make `Opencode::Turn` submit `prompt_async` through the transport's
|
||||||
|
at-most-once `on_subscribed` callback, after `server.connected` proves the
|
||||||
|
SSE listener is ready. Reconnects reopen only SSE and never replay the user
|
||||||
|
prompt.
|
||||||
|
- Fail the turn directly when subscription setup or the prompt POST fails
|
||||||
|
before a turn is confirmed started. The recovery path no longer risks
|
||||||
|
finalizing stale exchange text after a pre-turn failure.
|
||||||
|
- Add a gem-level behavioral regression for the cross-gem ordering contract,
|
||||||
|
including reconnect and ambiguous prompt timeout cases.
|
||||||
|
|
||||||
|
### Bumped
|
||||||
|
|
||||||
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha7`.
|
||||||
|
|
||||||
|
## 0.0.1.alpha6 - 2026-07-18
|
||||||
|
|
||||||
|
### Bumped
|
||||||
|
|
||||||
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha6`. This exposed
|
||||||
|
subscribe-before-prompt through `Client#stream`, but the lower-level
|
||||||
|
`Opencode::Turn` path still sent before `stream_events`; that orchestration
|
||||||
|
gap is fixed in alpha7.
|
||||||
|
|
||||||
|
## 0.0.1.alpha5 - 2026-07-15
|
||||||
|
|
||||||
|
### Bumped
|
||||||
|
|
||||||
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha5`, exposing
|
||||||
|
native parent-linked and configured session creation to Rails hosts.
|
||||||
|
|
||||||
## 0.0.1.alpha4 - 2026-07-12
|
## 0.0.1.alpha4 - 2026-07-12
|
||||||
|
|
||||||
### Bumped
|
### Bumped
|
||||||
|
|||||||
4
Gemfile
4
Gemfile
@@ -1,3 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "opencode-ruby",
|
||||||
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
|
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||||
|
|
||||||
gemspec
|
gemspec
|
||||||
|
|||||||
133
README.md
133
README.md
@@ -17,17 +17,55 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# Gemfile
|
# Gemfile
|
||||||
gem "opencode-ruby" # wire client + Reply state machine
|
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
|
||||||
gem "opencode-rails" # AR-coupled session/turn/artifact stack
|
gem "opencode-rails", "= 0.0.1.alpha9"
|
||||||
|
```
|
||||||
|
|
||||||
|
Until publication is verified, validate this candidate checkout against the
|
||||||
|
exact `opencode-ruby` source it was tested with:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Gemfile
|
||||||
|
gem "opencode-ruby",
|
||||||
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
|
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||||
|
|
||||||
|
gem "opencode-rails",
|
||||||
|
path: "../opencode-rails"
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle install
|
bundle install
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
|
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
|
||||||
|
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
|
||||||
|
|
||||||
|
`UploadedFilesPrompt` anchors upload writes through `/proc/self/fd` or
|
||||||
|
`/dev/fd` to prevent sandbox-root path swaps. Hosts using upload copying must
|
||||||
|
provide one of those traversable descriptor filesystems; unsupported platforms
|
||||||
|
fail closed before writing.
|
||||||
|
|
||||||
|
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
|
||||||
|
retains the subscribe-ready-before-prompt transport contract and reconnects an
|
||||||
|
accepted turn without posting its prompt again, while hardening SSE framing.
|
||||||
|
|
||||||
|
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
|
||||||
|
`opencode-rails` alpha8 was never published.
|
||||||
|
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
|
||||||
|
published on RubyGems. Because the gem has no RubyGems entry yet, create a
|
||||||
|
pending trusted publisher for gem `opencode-rails`, repository owner
|
||||||
|
`ajaynomics`, repository `opencode-rails`, workflow `release.yml`, and
|
||||||
|
environment `release`. RubyGems converts it to a normal trusted publisher after
|
||||||
|
the first successful push. The workflow also refuses to publish until the
|
||||||
|
RubyGems `opencode-ruby` alpha9 package exists and installs and loads with the
|
||||||
|
locally built Rails package. Until the registry result is verified, pushing a
|
||||||
|
`v*` tag does not guarantee publication. Trusted publishing does not require a
|
||||||
|
long-lived RubyGems API key.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -41,13 +79,35 @@ Opencode::ErrorReporter.adapter = ->(error, **opts) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# app/services/noop_reply_observer.rb
|
||||||
|
#
|
||||||
|
# Turn requires an observer factory even when the app does not need live
|
||||||
|
# partial rendering. For a streaming UI, replace this with an observer that
|
||||||
|
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
|
||||||
|
class NoopReplyObserver
|
||||||
|
include Opencode::ReplyObserver
|
||||||
|
|
||||||
|
def watch(reply)
|
||||||
|
reply.add_observer(self)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# app/jobs/generate_response_job.rb
|
# app/jobs/generate_response_job.rb
|
||||||
class GenerateResponseJob < ApplicationJob
|
class GenerateResponseJob < ApplicationJob
|
||||||
def perform(assistant_message)
|
def perform(assistant_message, user_message)
|
||||||
conversation = assistant_message.conversation
|
conversation = assistant_message.conversation
|
||||||
user_message = conversation.messages.where(role: :user).last
|
unless user_message.conversation_id == conversation.id
|
||||||
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"])
|
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
||||||
|
end
|
||||||
|
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
|
||||||
|
client = Opencode::Client.new(
|
||||||
|
base_url: ENV.fetch("OPENCODE_URL"),
|
||||||
|
directory: working_directory
|
||||||
|
)
|
||||||
|
|
||||||
session = Opencode::Session.new(
|
session = Opencode::Session.new(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -60,9 +120,15 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
subject: conversation,
|
subject: conversation,
|
||||||
query_text: user_message.content,
|
query_text: user_message.content,
|
||||||
client: client,
|
client: client,
|
||||||
session: session,
|
session_for: session,
|
||||||
|
observer_factory: ->(_message) { NoopReplyObserver.new },
|
||||||
|
system_context: ->(record) { "You are assisting with #{record.title}." },
|
||||||
|
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
|
||||||
|
tracer: ->(name, **payload) {
|
||||||
|
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
|
||||||
|
},
|
||||||
on_turn_finished: ->(result) {
|
on_turn_finished: ->(result) {
|
||||||
# result.status #=> :completed | :error | :cancelled
|
# result.status #=> :completed | :cancelled | :error | :failed
|
||||||
# result.message #=> the AR row (reloaded)
|
# result.message #=> the AR row (reloaded)
|
||||||
# result.duration_ms
|
# result.duration_ms
|
||||||
}
|
}
|
||||||
@@ -71,15 +137,41 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permission_rules_for(conversation)
|
def permission_rules_for(_conversation)
|
||||||
[
|
[
|
||||||
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" }
|
{ permission: "*", pattern: "*", action: "deny" }
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`.
|
Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
|
||||||
|
project configuration, plugins, and instructions while opening a directory,
|
||||||
|
before session permissions exist; the wildcard deny rule cannot constrain that
|
||||||
|
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
|
||||||
|
mounted at the same absolute path in Rails and OpenCode. Treat the server's
|
||||||
|
global configuration, plugins, and instructions as privileged administrator
|
||||||
|
code and verify the setting against the deployed OpenCode version.
|
||||||
|
|
||||||
|
This quickstart intentionally grants no filesystem tools: OpenCode permissions
|
||||||
|
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
|
||||||
|
process also has an independent container or operating-system boundary.
|
||||||
|
|
||||||
|
`Opencode::Session` applies permissions only when it creates a session. Before
|
||||||
|
deploying a directory or permission-policy change, recreate persisted sessions
|
||||||
|
with a client scoped to the new directory; an existing session ID does not
|
||||||
|
prove that the new policy was applied.
|
||||||
|
|
||||||
|
The host record (here `conversation`) must respond to `#title`,
|
||||||
|
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
|
||||||
|
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
|
||||||
|
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
|
||||||
|
`#error!(content)`. A non-no-op observer may impose additional record methods
|
||||||
|
for its own live snapshots.
|
||||||
|
|
||||||
|
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
|
||||||
|
constructor is intentionally explicit and may change before 1.0, so keep this
|
||||||
|
wiring in one host service/job and keep the gem source pinned exactly.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@@ -102,17 +194,18 @@ Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Trace
|
|||||||
|
|
||||||
## Instrumentation + error reporting
|
## Instrumentation + error reporting
|
||||||
|
|
||||||
The gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above).
|
The wire client emits `opencode.request` and artifact extraction emits
|
||||||
|
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
|
||||||
|
Turn events flow through the injected tracer; with the Quickstart's
|
||||||
|
`assistant.` prefix they include:
|
||||||
|
|
||||||
Events emitted (non-exhaustive):
|
- `assistant.response.started`, `assistant.turn.finished`
|
||||||
|
- `assistant.stream.completed`, `assistant.stream.interrupted`
|
||||||
|
- `assistant.session.created`, `assistant.session.recreated_with_resend`
|
||||||
|
- `assistant.response.upstream_error`
|
||||||
|
|
||||||
- `opencode.turn.started`, `opencode.turn.finished`
|
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
|
||||||
- `opencode.stream.completed`, `opencode.stream.interrupted`
|
Instrumentation and error reporting are no-ops until the host wires adapters.
|
||||||
- `opencode.session.created`, `opencode.session.recreated`
|
|
||||||
- `opencode.apply_patch.artifacts_dropped`
|
|
||||||
- `opencode.response.upstream_error`
|
|
||||||
|
|
||||||
Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter.
|
|
||||||
|
|
||||||
## Position
|
## Position
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,10 @@
|
|||||||
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
|
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
|
||||||
# 4. Permissions builder: per-product session permission rules.
|
# 4. Permissions builder: per-product session permission rules.
|
||||||
#
|
#
|
||||||
# Tested patterns. Every line below has been exercised in production.
|
# Contract-tested reference. Copy what you need and adapt it to your domain.
|
||||||
# Copy what you need; adapt 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
|
# 1. config/initializers/opencode.rb
|
||||||
@@ -39,10 +41,8 @@
|
|||||||
# events to its observability stack.
|
# events to its observability stack.
|
||||||
|
|
||||||
Rails.application.config.to_prepare do
|
Rails.application.config.to_prepare do
|
||||||
# opencode-ruby: every HTTP request, SSE event lifecycle, recovery
|
# opencode-ruby HTTP requests flow through this adapter as
|
||||||
# path flows through this adapter as an ActiveSupport::Notifications
|
# `opencode.request` ActiveSupport::Notifications events.
|
||||||
# event. Subscribers in this app pick it up via
|
|
||||||
# `ActiveSupport::Notifications.subscribe("opencode.*", ...)`.
|
|
||||||
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
||||||
ActiveSupport::Notifications.instrument(name, payload, &block)
|
ActiveSupport::Notifications.instrument(name, payload, &block)
|
||||||
}
|
}
|
||||||
@@ -70,18 +70,26 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
# SolidQueue concurrency_key — only one turn per conversation in
|
# SolidQueue concurrency_key — only one turn per conversation in
|
||||||
# flight at a time. Without this, a user sending two messages back-
|
# flight at a time. Without this, a user sending two messages back-
|
||||||
# to-back can race two turns through the same OpenCode session.
|
# to-back can race two turns through the same OpenCode session.
|
||||||
limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" }
|
limits_concurrency to: 1, key: ->(message, _user_message) { "GenerateResponseJob/#{message.conversation_id}" }
|
||||||
|
|
||||||
def perform(assistant_message)
|
def perform(assistant_message, user_message)
|
||||||
return if assistant_message.terminal? # idempotent
|
return if assistant_message.terminal? # idempotent
|
||||||
|
|
||||||
conversation = assistant_message.conversation
|
conversation = assistant_message.conversation
|
||||||
user_message = conversation.messages.where(role: :user).order(:created_at).last
|
unless user_message.conversation_id == conversation.id
|
||||||
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
||||||
|
end
|
||||||
|
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
|
||||||
|
client = Opencode::Client.new(
|
||||||
|
base_url: ENV.fetch("OPENCODE_URL"),
|
||||||
|
directory: working_directory
|
||||||
|
)
|
||||||
|
|
||||||
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
||||||
# OpenCode session if conversation.opencode_session_id is blank;
|
# OpenCode session if conversation.opencode_session_id is blank;
|
||||||
# returns the existing id otherwise.
|
# 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(
|
session = Opencode::Session.new(
|
||||||
conversation,
|
conversation,
|
||||||
permissions_for: ->(record) { permission_rules_for(record) },
|
permissions_for: ->(record) { permission_rules_for(record) },
|
||||||
@@ -96,11 +104,13 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
subject: conversation,
|
subject: conversation,
|
||||||
query_text: user_message.content,
|
query_text: user_message.content,
|
||||||
client: client,
|
client: client,
|
||||||
session_for: ->(*) { session },
|
session_for: session,
|
||||||
observer_factory: ->(message) { ReplyStream.new(message: message) },
|
observer_factory: ->(message) { ReplyStream.new(message: message) },
|
||||||
system_context: build_system_context(conversation),
|
system_context: ->(record) { build_system_context(record) },
|
||||||
agent_name: "default",
|
agent_name: ->(_record) { "build" },
|
||||||
tracer: Opencode::Tracer.new(prefix: "opencode."),
|
tracer: ->(name, **payload) {
|
||||||
|
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
|
||||||
|
},
|
||||||
on_turn_finished: ->(result) {
|
on_turn_finished: ->(result) {
|
||||||
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
|
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
|
||||||
}
|
}
|
||||||
@@ -113,23 +123,21 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permission_rules_for(conversation)
|
def permission_rules_for(_conversation)
|
||||||
# Per-product permissions. The shape mirrors what
|
# Per-product permissions. The shape mirrors what
|
||||||
# opencode-ruby's Client#create_session expects in `permissions:`.
|
# opencode-ruby's Client#create_session expects in `permissions:`.
|
||||||
[
|
[
|
||||||
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" },
|
{ permission: "*", pattern: "*", action: "deny" }
|
||||||
{ type: "edit", action: "deny", path: "*" } # default deny outside sandbox
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_system_context(conversation)
|
def build_system_context(conversation)
|
||||||
# System prompt context the agent gets. Your app probably already
|
# System prompt context the agent gets. Your app probably already
|
||||||
# has helpers for this; the gem doesn't impose a shape.
|
# has helpers for this; the gem doesn't impose a shape.
|
||||||
{
|
<<~CONTEXT
|
||||||
user_name: conversation.user.name,
|
User: #{conversation.user.name}
|
||||||
conversation_id: conversation.id,
|
Conversation: #{conversation.id}
|
||||||
sandbox_path: "/sandbox/#{conversation.id}"
|
CONTEXT
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -138,7 +146,7 @@ end
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# An Opencode::ReplyObserver implementation that bridges the gem's
|
# An Opencode::ReplyObserver implementation that bridges the gem's
|
||||||
# state-machine callbacks (part_appended, part_updated, finalized) to
|
# state-machine callbacks (part_added, part_changed, part_finalized) to
|
||||||
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
|
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
|
||||||
# the rendering.
|
# the rendering.
|
||||||
#
|
#
|
||||||
@@ -147,51 +155,47 @@ end
|
|||||||
# build_system_context above.
|
# build_system_context above.
|
||||||
|
|
||||||
class ReplyStream
|
class ReplyStream
|
||||||
|
include Opencode::ReplyObserver
|
||||||
|
|
||||||
def initialize(message:)
|
def initialize(message:)
|
||||||
@message = message
|
@message = message
|
||||||
@parts_dom_id = "parts_message_#{message.id}"
|
@parts_dom_id = "parts_message_#{message.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Called every time a new part shows up in the reply.
|
def watch(reply)
|
||||||
def on_part_appended(part)
|
reply.add_observer(self)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def part_added(part:, index:)
|
||||||
Turbo::StreamsChannel.broadcast_append_to(
|
Turbo::StreamsChannel.broadcast_append_to(
|
||||||
@message.conversation,
|
@message.conversation,
|
||||||
target: @parts_dom_id,
|
target: @parts_dom_id,
|
||||||
partial: "messages/part",
|
partial: "messages/part",
|
||||||
locals: { part: part, message: @message }
|
locals: { part: part, index: index, message: @message }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Called when an existing part's content grows (text/reasoning
|
def part_changed(part:, index:, delta:)
|
||||||
# deltas, tool state changes).
|
broadcast_update(part, index)
|
||||||
def on_part_updated(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(
|
Turbo::StreamsChannel.broadcast_update_to(
|
||||||
@message.conversation,
|
@message.conversation,
|
||||||
target: "part_#{part['id']}_message_#{@message.id}",
|
target: "part_#{index}_message_#{@message.id}",
|
||||||
partial: "messages/part",
|
partial: "messages/part",
|
||||||
locals: { part: part, message: @message }
|
locals: { part: part, index: index, message: @message }
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Called when the turn finalizes. Use this to swap "Thinking…"
|
|
||||||
# placeholders, update message status indicators, etc.
|
|
||||||
def on_finalized(reply_result)
|
|
||||||
Turbo::StreamsChannel.broadcast_update_to(
|
|
||||||
@message.conversation,
|
|
||||||
target: "message_#{@message.id}_status",
|
|
||||||
partial: "messages/status",
|
|
||||||
locals: { message: @message, result: reply_result }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Optional: called when the gem catches an exception mid-stream and
|
|
||||||
# has done its own recovery (recreated session, retried, etc.). Use
|
|
||||||
# this for a brief transient banner in the UI.
|
|
||||||
def on_error(message, severity:)
|
|
||||||
Turbo::StreamsChannel.broadcast_replace_to(
|
|
||||||
@message.conversation,
|
|
||||||
target: "message_#{@message.id}_status",
|
|
||||||
html: %(<div class="banner banner--warn">#{message}</div>)
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -201,9 +205,7 @@ end
|
|||||||
# - Idempotent session create/resolve with row-level locking
|
# - Idempotent session create/resolve with row-level locking
|
||||||
# - SSE stream consumption + reconnection on transport hiccups
|
# - SSE stream consumption + reconnection on transport hiccups
|
||||||
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
|
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
|
||||||
# - Mid-stream parts_json snapshotting via update_columns (bypasses
|
# - ReplyObserver callbacks for host-owned live broadcasts or snapshots
|
||||||
# after_save callbacks; your row-level Turbo broadcasts fire on
|
|
||||||
# YOUR cadence, not every SSE event)
|
|
||||||
# - CAS-safe finalize: message reloaded under row lock, transitions
|
# - CAS-safe finalize: message reloaded under row lock, transitions
|
||||||
# :pending -> :completed only if a concurrent cancel hasn't already
|
# :pending -> :completed only if a concurrent cancel hasn't already
|
||||||
# moved it out of :pending.
|
# moved it out of :pending.
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
# rename work.
|
# rename work.
|
||||||
|
|
||||||
require "opencode-ruby"
|
require "opencode-ruby"
|
||||||
|
require "fileutils"
|
||||||
|
require "marcel"
|
||||||
|
require "pathname"
|
||||||
|
require "set"
|
||||||
|
require "stringio"
|
||||||
|
require "tempfile"
|
||||||
|
|
||||||
require "active_support/core_ext/object/blank" # blank?, present?, presence
|
require "active_support/core_ext/object/blank" # blank?, present?, presence
|
||||||
require "active_support/core_ext/object/try"
|
require "active_support/core_ext/object/try"
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ module Opencode
|
|||||||
|
|
||||||
if (transform = transforms.find { |t| t.applies_to?(file) })
|
if (transform = transforms.find { |t| t.applies_to?(file) })
|
||||||
attached += 1 if apply_transform(transform, file)
|
attached += 1 if apply_transform(transform, file)
|
||||||
|
elsif transform_owned_filenames.include?(file.basename)
|
||||||
|
# Destination names belong exclusively to their host transform.
|
||||||
|
# Agent-authored bytes must never fall through as trusted output.
|
||||||
|
next
|
||||||
elsif default_attach == :all
|
elsif default_attach == :all
|
||||||
# Default identity path: every safe sandbox file that no
|
# Default identity path: every safe sandbox file that no
|
||||||
# transform claims attaches as-is. Callers that want the
|
# transform claims attaches as-is. Callers that want the
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
# We can't reuse the same constant from a second gem, so we use a
|
# We can't reuse the same constant from a second gem, so we use a
|
||||||
# distinct, non-namespaced constant.
|
# distinct, non-namespaced constant.
|
||||||
module Opencode
|
module Opencode
|
||||||
RAILS_VERSION = "0.0.1.alpha4"
|
RAILS_VERSION = "0.0.1.alpha9"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ module Opencode
|
|||||||
# not here — the file doesn't know which turn opened "after." That's
|
# not here — the file doesn't know which turn opened "after." That's
|
||||||
# a property of the scan, not a property of the file.
|
# a property of the scan, not a property of the file.
|
||||||
class SandboxFile
|
class SandboxFile
|
||||||
|
UnsafeFileError = Class.new(Opencode::Error)
|
||||||
|
|
||||||
attr_reader :path, :sandbox_prefix
|
attr_reader :path, :sandbox_prefix
|
||||||
|
|
||||||
def initialize(path:, sandbox_prefix:, max_bytes:)
|
def initialize(path:, sandbox_prefix:, max_bytes:)
|
||||||
@@ -36,7 +38,13 @@ module Opencode
|
|||||||
end
|
end
|
||||||
|
|
||||||
def content
|
def content
|
||||||
File.read(path)
|
with_safe_file do |file|
|
||||||
|
content = file.read(@max_bytes + 1) || "".b
|
||||||
|
if content.bytesize > @max_bytes
|
||||||
|
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
|
||||||
|
end
|
||||||
|
content
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
@@ -52,17 +60,11 @@ module Opencode
|
|||||||
# - Reject anything over the size cap (default
|
# - Reject anything over the size cap (default
|
||||||
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
|
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
|
||||||
#
|
#
|
||||||
# The Sandbox scan filters non-files (directories, FIFOs) before
|
# Revalidates the opened file descriptor so a path swap between the
|
||||||
# yielding, so we don't re-check #file? here.
|
# sandbox scan and the read cannot redirect content outside the sandbox.
|
||||||
def safe?
|
def safe?
|
||||||
return false if File.symlink?(path)
|
with_safe_file { true }
|
||||||
return false unless Pathname.new(path).realpath.to_s.start_with?(sandbox_prefix)
|
rescue UnsafeFileError
|
||||||
return false if size > @max_bytes
|
|
||||||
|
|
||||||
true
|
|
||||||
rescue Errno::ENOENT
|
|
||||||
# Concurrent deletion between scan-yield and safety-check — treat
|
|
||||||
# as unsafe so the orchestrator skips rather than crashing.
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -77,5 +79,46 @@ module Opencode
|
|||||||
content_type: content_type
|
content_type: content_type
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def with_safe_file
|
||||||
|
before = File.lstat(path)
|
||||||
|
unless before.file? && !before.symlink? && before.nlink == 1
|
||||||
|
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
|
||||||
|
end
|
||||||
|
|
||||||
|
resolved = Pathname.new(path).realpath.to_s
|
||||||
|
unless resolved.start_with?(sandbox_prefix)
|
||||||
|
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
|
||||||
|
end
|
||||||
|
|
||||||
|
flags = safe_open_flags
|
||||||
|
File.open(path, flags, encoding: Encoding::BINARY) do |file|
|
||||||
|
opened = file.stat
|
||||||
|
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
|
||||||
|
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
|
||||||
|
end
|
||||||
|
if opened.size > @max_bytes
|
||||||
|
raise UnsafeFileError, "Sandbox file exceeds size limit: #{basename}"
|
||||||
|
end
|
||||||
|
|
||||||
|
yield file
|
||||||
|
end
|
||||||
|
rescue SystemCallError => e
|
||||||
|
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_open_flags
|
||||||
|
required = %i[NONBLOCK NOFOLLOW]
|
||||||
|
missing = required.reject { |name| File.const_defined?(name) }
|
||||||
|
unless missing.empty?
|
||||||
|
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
|
||||||
|
end
|
||||||
|
|
||||||
|
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
|
||||||
|
flags |= File::BINARY if File.const_defined?(:BINARY)
|
||||||
|
flags
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ module Opencode
|
|||||||
# trusted?(attachment) — true if the attachment was produced by
|
# trusted?(attachment) — true if the attachment was produced by
|
||||||
# this transform (used by Impostor.for and
|
# this transform (used by Impostor.for and
|
||||||
# by view code that decides inline-render
|
# by view code that decides inline-render
|
||||||
# vs download). Default: filename match.
|
# vs download). Default: false; subclasses
|
||||||
|
# must verify host-authored metadata.
|
||||||
# purge_impostors? — if true, before attaching the substrate
|
# purge_impostors? — if true, before attaching the substrate
|
||||||
# deletes any existing attachment whose
|
# deletes any existing attachment whose
|
||||||
# filename matches destination_filename
|
# filename matches destination_filename
|
||||||
@@ -60,8 +61,8 @@ module Opencode
|
|||||||
raise NotImplementedError, "#{self.class.name} must implement #render"
|
raise NotImplementedError, "#{self.class.name} must implement #render"
|
||||||
end
|
end
|
||||||
|
|
||||||
def trusted?(attachment)
|
def trusted?(_attachment)
|
||||||
attachment.filename.to_s == destination_filename
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def purge_impostors?
|
def purge_impostors?
|
||||||
|
|||||||
@@ -213,12 +213,6 @@ module Opencode
|
|||||||
emit_session_created_if_new
|
emit_session_created_if_new
|
||||||
validate_session!(session_id)
|
validate_session!(session_id)
|
||||||
|
|
||||||
@client.send_message_async(
|
|
||||||
session_id, @query_text,
|
|
||||||
agent: @agent_name.call(@subject),
|
|
||||||
system: @system_context.call(@subject)
|
|
||||||
)
|
|
||||||
|
|
||||||
stream_result = stream_response(session_id)
|
stream_result = stream_response(session_id)
|
||||||
exchange = fetch_current_exchange(session_id)
|
exchange = fetch_current_exchange(session_id)
|
||||||
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
|
stream_result, exchange = wait_for_final_exchange_result(session_id, stream_result, exchange)
|
||||||
@@ -260,6 +254,22 @@ module Opencode
|
|||||||
last_activity_touch_at = stream_started_at
|
last_activity_touch_at = stream_started_at
|
||||||
first_token_at = nil
|
first_token_at = nil
|
||||||
event_count = 0
|
event_count = 0
|
||||||
|
prompt_attempted = false
|
||||||
|
prompt_succeeded = false
|
||||||
|
on_subscribed = lambda do
|
||||||
|
# stream_events guarantees at-most-once invocation, but keep this
|
||||||
|
# guard here as a second line of defense because an ambiguous prompt
|
||||||
|
# response must never become a duplicate model turn.
|
||||||
|
next false if prompt_attempted
|
||||||
|
|
||||||
|
prompt_attempted = true
|
||||||
|
@client.send_message_async(
|
||||||
|
session_id, @query_text,
|
||||||
|
agent: @agent_name.call(@subject),
|
||||||
|
system: @system_context.call(@subject)
|
||||||
|
)
|
||||||
|
prompt_succeeded = true
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
release_active_record_connections
|
release_active_record_connections
|
||||||
@@ -293,7 +303,8 @@ module Opencode
|
|||||||
@client.stream_events(
|
@client.stream_events(
|
||||||
session_id: session_id,
|
session_id: session_id,
|
||||||
reply: reply,
|
reply: reply,
|
||||||
on_activity_tick: activity_tick
|
on_activity_tick: activity_tick,
|
||||||
|
on_subscribed: on_subscribed
|
||||||
) do |event|
|
) do |event|
|
||||||
event_count += 1
|
event_count += 1
|
||||||
reply.apply(event)
|
reply.apply(event)
|
||||||
@@ -308,6 +319,13 @@ module Opencode
|
|||||||
rescue Opencode::SessionNotFoundError
|
rescue Opencode::SessionNotFoundError
|
||||||
raise
|
raise
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
# Subscription rejection or prompt transport failure happened before
|
||||||
|
# a turn was confirmed started. Recovering from the pre-turn exchange
|
||||||
|
# could finalize stale text, and retrying an ambiguous POST could
|
||||||
|
# duplicate spend, so surface the original failure to the outer error
|
||||||
|
# path without reconnect/recovery.
|
||||||
|
raise unless prompt_succeeded
|
||||||
|
|
||||||
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
|
Opencode::ErrorReporter.report(e, handled: true, severity: :warning,
|
||||||
context: { feature: @error_feature, error_class: e.class.name })
|
context: { feature: @error_feature, error_class: e.class.name })
|
||||||
emit("stream.interrupted",
|
emit("stream.interrupted",
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ module Opencode
|
|||||||
# Side effect, unchanged from the concern: file bytes are copied from
|
# Side effect, unchanged from the concern: file bytes are copied from
|
||||||
# ActiveStorage into the per-user OpenCode sandbox directory so the
|
# ActiveStorage into the per-user OpenCode sandbox directory so the
|
||||||
# agent can read them with the `read` tool. The copy is path-escape
|
# agent can read them with the `read` tool. The copy is path-escape
|
||||||
# guarded (the cleanpath of the destination must start with the
|
# guarded: generated names must be basenames, and all writes stay anchored
|
||||||
# sandbox dir prefix, no symlink trickery).
|
# to an opened directory handle even if the sandbox path is replaced.
|
||||||
|
# A temporary file is renamed into place so destination symlinks are
|
||||||
|
# replaced rather than followed. Retried jobs can refresh existing copies.
|
||||||
class UploadedFilesPrompt
|
class UploadedFilesPrompt
|
||||||
attr_reader :text, :sandbox_file_names
|
attr_reader :text, :sandbox_file_names
|
||||||
|
|
||||||
@@ -52,24 +54,77 @@ module Opencode
|
|||||||
[
|
[
|
||||||
raw,
|
raw,
|
||||||
"",
|
"",
|
||||||
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
|
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly before responding:",
|
||||||
*file_instructions
|
*file_instructions
|
||||||
].join("\n").strip
|
].join("\n").strip
|
||||||
end
|
end
|
||||||
|
|
||||||
def copy_to_sandbox(file)
|
def copy_to_sandbox(file)
|
||||||
FileUtils.mkdir_p(@sandbox_path)
|
sandbox_path = Pathname.new(@sandbox_path).expand_path
|
||||||
|
FileUtils.mkdir_p(sandbox_path)
|
||||||
|
sandbox_stat = File.lstat(sandbox_path)
|
||||||
|
unless sandbox_stat.directory? && !sandbox_stat.symlink?
|
||||||
|
raise ArgumentError, "Sandbox root must be a directory, not a symlink: #{sandbox_path}"
|
||||||
|
end
|
||||||
|
|
||||||
sandbox_name = @sandbox_name_for.call(file)
|
sandbox_name = @sandbox_name_for.call(file).to_s
|
||||||
dest = File.join(@sandbox_path, sandbox_name)
|
unless sandbox_name == File.basename(sandbox_name) && !%w[. ..].include?(sandbox_name)
|
||||||
|
|
||||||
resolved = Pathname.new(dest).cleanpath.to_s
|
|
||||||
unless resolved.start_with?(@sandbox_path)
|
|
||||||
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
|
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
File.open(dest, "wb") { |f| f.write(file.download) }
|
File.open(sandbox_path, File::RDONLY) do |directory|
|
||||||
Placement.new(sandbox_name, dest)
|
opened_stat = directory.stat
|
||||||
|
unless opened_stat.directory? && same_file?(sandbox_stat, opened_stat)
|
||||||
|
raise ArgumentError, "Sandbox root changed while opening: #{sandbox_path}"
|
||||||
|
end
|
||||||
|
|
||||||
|
directory_path = directory_handle_path(directory)
|
||||||
|
dest = File.join(directory_path, sandbox_name)
|
||||||
|
mode = destination_mode(dest)
|
||||||
|
|
||||||
|
Tempfile.create([ ".opencode-upload-", ".tmp" ], directory_path) do |temp|
|
||||||
|
temp.binmode
|
||||||
|
temp.write(file.download)
|
||||||
|
temp.flush
|
||||||
|
temp.chmod(mode)
|
||||||
|
File.rename(temp.path, dest)
|
||||||
|
|
||||||
|
unless same_file_at_path?(sandbox_path, opened_stat)
|
||||||
|
File.unlink(dest)
|
||||||
|
raise ArgumentError, "Sandbox root changed during upload: #{sandbox_path}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Placement.new(sandbox_name, sandbox_path.join(sandbox_name).to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def directory_handle_path(directory)
|
||||||
|
stat = directory.stat
|
||||||
|
[ "/proc/self/fd/#{directory.fileno}", "/dev/fd/#{directory.fileno}" ].find do |path|
|
||||||
|
same_file?(File.stat(path), stat)
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
false
|
||||||
|
end || raise(ArgumentError, "Platform cannot anchor writes to the opened sandbox directory")
|
||||||
|
end
|
||||||
|
|
||||||
|
def destination_mode(path)
|
||||||
|
stat = File.lstat(path)
|
||||||
|
return stat.mode & 0o777 if stat.file? && !stat.symlink?
|
||||||
|
|
||||||
|
0o666 & ~File.umask
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
0o666 & ~File.umask
|
||||||
|
end
|
||||||
|
|
||||||
|
def same_file_at_path?(path, expected)
|
||||||
|
current = File.lstat(path)
|
||||||
|
current.directory? == expected.directory? && !current.symlink? && same_file?(current, expected)
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def same_file?(left, right)
|
||||||
|
left.dev == right.dev && left.ino == right.ino
|
||||||
end
|
end
|
||||||
|
|
||||||
# Tiny value pair returned by copy_to_sandbox: the canonical filename
|
# Tiny value pair returned by copy_to_sandbox: the canonical filename
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ Gem::Specification.new do |spec|
|
|||||||
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
||||||
# exactly (= not ~>) so that consumers always pick the version this gem
|
# exactly (= not ~>) so that consumers always pick the version this gem
|
||||||
# was tested against.
|
# was tested against.
|
||||||
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha4"
|
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
|
||||||
|
spec.add_runtime_dependency "marcel", "~> 1.0"
|
||||||
|
|
||||||
# Rails sub-libraries used at runtime. Depending on these individually
|
# Rails sub-libraries used at runtime. Depending on these individually
|
||||||
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
||||||
|
|||||||
67
test/example_test.rb
Normal file
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
require "open3"
|
||||||
|
|
||||||
# Smoke test: every constant the gem promises is defined and points at
|
# Smoke test: every constant the gem promises is defined and points at
|
||||||
# the right kind of object. If require "opencode-rails" loads cleanly,
|
# the right kind of object. If require "opencode-rails" loads cleanly,
|
||||||
@@ -36,23 +37,26 @@ class Opencode::LoadingTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# We check via path match on both directory ("/opencode-rails/") and
|
# Match the gem's own lib path, not merely any parent directory. GitHub's
|
||||||
# installed-gem name ("/opencode-rails-VERSION/") so the assertion is
|
# checkout layout nests Bundler under /opencode-rails/opencode-rails, so a
|
||||||
# robust to either a sibling-repo dev setup or a bundle-resolved gem
|
# broad directory-name assertion falsely classifies a bundled
|
||||||
# install.
|
# opencode-ruby source path as belonging to this gem.
|
||||||
GEM_PATH_PATTERN = ->(name) { %r{/#{Regexp.escape(name)}[-/]} }
|
GEM_SOURCE_PATTERN = lambda do |name, file|
|
||||||
|
%r{/#{Regexp.escape(name)}(?:-[^/]+)?/lib/opencode/#{Regexp.escape(file)}\.rb\z}
|
||||||
|
end
|
||||||
|
|
||||||
def test_session_constant_points_at_this_gem
|
def test_session_constant_points_at_this_gem
|
||||||
location = Opencode::Session.instance_method(:initialize).source_location.first
|
location = Opencode::Session.instance_method(:initialize).source_location.first
|
||||||
assert_match GEM_PATH_PATTERN.call("opencode-rails"), location,
|
expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
|
||||||
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
|
assert_equal expected, File.expand_path(location),
|
||||||
|
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_client_constant_points_at_opencode_ruby
|
def test_client_constant_points_at_opencode_ruby
|
||||||
location = Opencode::Client.instance_method(:initialize).source_location.first
|
location = Opencode::Client.instance_method(:initialize).source_location.first
|
||||||
assert_match GEM_PATH_PATTERN.call("opencode-ruby"), location,
|
assert_match GEM_SOURCE_PATTERN.call("opencode-ruby", "client"), location,
|
||||||
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
|
"Expected Opencode::Client to come from opencode-ruby, got: #{location}"
|
||||||
refute_match GEM_PATH_PATTERN.call("opencode-rails"), 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)"
|
"Opencode::Client must NOT come from opencode-rails (it's an opencode-ruby class)"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -69,4 +73,24 @@ class Opencode::LoadingTest < Minitest::Test
|
|||||||
refute Opencode.const_defined?(:Rails),
|
refute Opencode.const_defined?(:Rails),
|
||||||
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
|
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_umbrella_require_loads_runtime_standard_libraries
|
||||||
|
root = File.expand_path("../..", __dir__)
|
||||||
|
script = <<~'RUBY'
|
||||||
|
require "opencode-rails"
|
||||||
|
abort "StringIO missing" unless defined?(StringIO)
|
||||||
|
abort "Marcel missing" unless defined?(Marcel::MimeType)
|
||||||
|
abort "FileUtils missing" unless defined?(FileUtils)
|
||||||
|
abort "Tempfile missing" unless defined?(Tempfile)
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
_stdout, stderr, status = Open3.capture3(
|
||||||
|
Gem.ruby,
|
||||||
|
"-I#{File.join(root, "lib")}",
|
||||||
|
"-e",
|
||||||
|
script
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status.success?, stderr
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ require "test_helper"
|
|||||||
# coverage — including ActiveStorage attachment, Transform application,
|
# coverage — including ActiveStorage attachment, Transform application,
|
||||||
# error reporting via Opencode::ErrorReporter — lives in the host app.
|
# error reporting via Opencode::ErrorReporter — lives in the host app.
|
||||||
class Opencode::MessageArtifactsTest < Minitest::Test
|
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
|
def test_initialize_takes_message_feature_and_optional_transforms
|
||||||
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
|
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
|
||||||
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
|
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
|
||||||
@@ -23,4 +40,17 @@ class Opencode::MessageArtifactsTest < Minitest::Test
|
|||||||
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
|
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
|
||||||
"MessageArtifacts's only public verb is #attach_from"
|
"MessageArtifacts's only public verb is #attach_from"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
require "socket"
|
||||||
require "tmpdir"
|
require "tmpdir"
|
||||||
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
|||||||
class Opencode::SandboxFileTest < Minitest::Test
|
class Opencode::SandboxFileTest < Minitest::Test
|
||||||
def setup
|
def setup
|
||||||
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
|
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
|
||||||
|
@outside_dir = Dir.mktmpdir("opencode-rails-sandbox-file-outside-")
|
||||||
@path = File.join(@tmpdir, "notes.md")
|
@path = File.join(@tmpdir, "notes.md")
|
||||||
File.write(@path, "# hello\nworld\n")
|
File.write(@path, "# hello\nworld\n")
|
||||||
# SandboxFile uses `start_with?` against this prefix to detect path
|
# SandboxFile uses `start_with?` against this prefix to detect path
|
||||||
@@ -21,6 +23,7 @@ class Opencode::SandboxFileTest < Minitest::Test
|
|||||||
|
|
||||||
def teardown
|
def teardown
|
||||||
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
FileUtils.remove_entry(@outside_dir) if @outside_dir && File.exist?(@outside_dir)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_basic_readers
|
def test_basic_readers
|
||||||
@@ -59,4 +62,147 @@ class Opencode::SandboxFileTest < Minitest::Test
|
|||||||
assert_equal "notes.md", artifact.filename
|
assert_equal "notes.md", artifact.filename
|
||||||
assert_equal "# hello\nworld\n", artifact.content
|
assert_equal "# hello\nworld\n", artifact.content
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class Opencode::TransformTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
# A trivial concrete subclass exercises the defaults that DO exist
|
# A trivial concrete subclass exercises the defaults that DO exist
|
||||||
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
|
# (#applies_to? and #owned_filenames delegate to the
|
||||||
# two abstract filename methods).
|
# two abstract filename methods).
|
||||||
class FakeTransform < Opencode::Transform
|
class FakeTransform < Opencode::Transform
|
||||||
def source_filename = "agent-output.json"
|
def source_filename = "agent-output.json"
|
||||||
@@ -48,13 +48,11 @@ class Opencode::TransformTest < Minitest::Test
|
|||||||
refute transform.applies_to?(other)
|
refute transform.applies_to?(other)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_trusted_matches_destination_filename_by_default
|
def test_trusted_fails_closed_by_default
|
||||||
transform = FakeTransform.new
|
transform = FakeTransform.new
|
||||||
trusted = Attachment.new(filename: "rendered.html")
|
destination = Attachment.new(filename: "rendered.html")
|
||||||
untrusted = Attachment.new(filename: "agent-output.json")
|
|
||||||
|
|
||||||
assert transform.trusted?(trusted)
|
refute transform.trusted?(destination)
|
||||||
refute transform.trusted?(untrusted)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_owned_filenames_is_source_and_destination
|
def test_owned_filenames_is_source_and_destination
|
||||||
|
|||||||
@@ -3,11 +3,107 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
|
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
|
||||||
# Result value object. Behavioral coverage (the full send -> stream ->
|
# Result value object. Most ActiveRecord behavior lives in host applications,
|
||||||
# recover -> finalize loop) lives in the host application — Turn needs
|
# but the subscribe-before-prompt ordering is a cross-gem transport contract
|
||||||
# an Opencode::Client, an AR Message, a subject record, etc., which are
|
# and belongs here so a host cannot silently bypass opencode-ruby's guarantee.
|
||||||
# all integration-level concerns.
|
|
||||||
class Opencode::TurnTest < Minitest::Test
|
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[
|
REQUIRED_INIT_KEYS = %i[
|
||||||
message subject query_text client session_for observer_factory
|
message subject query_text client session_for observer_factory
|
||||||
system_context agent_name tracer
|
system_context agent_name tracer
|
||||||
@@ -59,4 +155,56 @@ class Opencode::TurnTest < Minitest::Test
|
|||||||
assert_equal 100, result.input_tokens
|
assert_equal 100, result.input_tokens
|
||||||
assert_equal 50, result.output_tokens
|
assert_equal 50, result.output_tokens
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
|
|||||||
EmptyFiles = Struct.new(:attached) do
|
EmptyFiles = Struct.new(:attached) do
|
||||||
def attached? = attached
|
def attached? = attached
|
||||||
end
|
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
|
def setup
|
||||||
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
||||||
@@ -52,4 +59,130 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
|
|||||||
assert_equal %i[sandbox_file_names text].sort,
|
assert_equal %i[sandbox_file_names text].sort,
|
||||||
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
||||||
end
|
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
|
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