diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 645542f..ca2b408 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,19 +40,19 @@ jobs: - name: Build gem run: gem build opencode-rails.gemspec - - name: Verify gems load after install + - name: Verify published client and installed gems run: | - client_dir="$(bundle show opencode-ruby)" - (cd "$client_dir" && gem build opencode-ruby.gemspec) - client_version="$(ruby -I"$client_dir/lib" -ropencode/version -e 'print Opencode::VERSION')" - client_gem="$client_dir/opencode-ruby-${client_version}.gem" + 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" - bundle_gem_path="$(bundle exec ruby -e 'puts Gem.path.join(":")')" + 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}:${bundle_gem_path}" + export GEM_PATH="${GEM_HOME}" mkdir -p "$GEM_HOME" - gem install --local "$client_gem" --no-document - gem install --local "$rails_gem" --no-document + 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| diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b71d3..be1c01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,10 @@ - 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 use a - realpath-validated per-conversation directory outside Git worktrees. Hosts - must add their own OS or container boundary before allowing filesystem tools. +- 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. @@ -34,6 +35,8 @@ 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 diff --git a/README.md b/README.md index bb708be..899c235 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,15 @@ 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. The -`release.yml` workflow is prepared for RubyGems -trusted publishing, but the gem's trusted publisher must be registered and -verified for that workflow and its `release` environment. 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. +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 @@ -100,10 +103,10 @@ class GenerateResponseJob < ApplicationJob unless user_message.conversation_id == conversation.id raise ArgumentError, "User and assistant messages must belong to the same conversation" end - sandbox_directory = sandbox_directory_for(conversation) + working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY")) client = Opencode::Client.new( base_url: ENV.fetch("OPENCODE_URL"), - directory: sandbox_directory.to_s + directory: working_directory ) session = Opencode::Session.new( @@ -139,43 +142,20 @@ class GenerateResponseJob < ApplicationJob { permission: "*", pattern: "*", action: "deny" } ] end - - def sandbox_directory_for(conversation) - identifier = conversation.id.to_s - unless identifier.match?(/\A[0-9A-Za-z_-]+\z/) - raise ArgumentError, "Conversation id is not safe for a directory name" - end - - root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path - root.mkpath - root = root.realpath - if root.ascend.any? { |directory| directory.join(".git").exist? } - raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree" - end - - directory = root.join(identifier) - directory.mkpath - stat = directory.lstat - unless stat.directory? && !stat.symlink? - raise ArgumentError, "Conversation sandbox must be a real directory" - end - - resolved = directory.realpath - unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") - raise ArgumentError, "Conversation sandbox escapes its root" - end - resolved - end end ``` -`OPENCODE_SANDBOX_ROOT` must be outside every Git worktree and mounted at the -same absolute path in the OpenCode server. OpenCode treats an entire detected -worktree as internal, so a directory inside the application repository is not -an `external_directory` boundary. 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 around the conversation directory. +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 diff --git a/examples/rails_integration.rb b/examples/rails_integration.rb index 034cfca..7d4bd3e 100644 --- a/examples/rails_integration.rb +++ b/examples/rails_integration.rb @@ -28,6 +28,9 @@ # 4. Permissions builder: per-product session permission rules. # # Contract-tested reference. Copy what you need and adapt it to your domain. +# The OpenCode server process must run with +# OPENCODE_DISABLE_PROJECT_CONFIG=1; session permissions are too late to +# constrain project configuration, plugins, or instructions at bootstrap. # ---------------------------------------------------------------------- # 1. config/initializers/opencode.rb @@ -76,10 +79,10 @@ class GenerateResponseJob < ApplicationJob unless user_message.conversation_id == conversation.id raise ArgumentError, "User and assistant messages must belong to the same conversation" end - sandbox_directory = sandbox_directory_for(conversation) + working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY")) client = Opencode::Client.new( base_url: ENV.fetch("OPENCODE_URL"), - directory: sandbox_directory.to_s + directory: working_directory ) # Session: AR-coupled, row-locked, idempotent. ensure! creates the @@ -128,42 +131,12 @@ class GenerateResponseJob < ApplicationJob ] end - def sandbox_directory_for(conversation) - # The root must be mounted at the same absolute path in the OpenCode server. - # Keeping it outside Git worktrees makes external_directory the boundary. - identifier = conversation.id.to_s - unless identifier.match?(/\A[0-9A-Za-z_-]+\z/) - raise ArgumentError, "Conversation id is not safe for a directory name" - end - - root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path - root.mkpath - root = root.realpath - if root.ascend.any? { |directory| directory.join(".git").exist? } - raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree" - end - - directory = root.join(identifier) - directory.mkpath - stat = directory.lstat - unless stat.directory? && !stat.symlink? - raise ArgumentError, "Conversation sandbox must be a real directory" - end - - resolved = directory.realpath - unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") - raise ArgumentError, "Conversation sandbox escapes its root" - end - resolved - end - def build_system_context(conversation) # System prompt context the agent gets. Your app probably already # has helpers for this; the gem doesn't impose a shape. <<~CONTEXT User: #{conversation.user.name} Conversation: #{conversation.id} - Sandbox: #{sandbox_directory_for(conversation)} CONTEXT end end diff --git a/test/example_test.rb b/test/example_test.rb index 405c9d5..8c9fc4c 100644 --- a/test/example_test.rb +++ b/test/example_test.rb @@ -37,19 +37,15 @@ class ExampleTest < Minitest::Test def test_example_uses_current_fail_closed_permission_rules refute_match(/\{\s*type:/, SOURCE) - assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)' - assert_includes SOURCE, 'directory: sandbox_directory.to_s' + 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, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")' - assert_includes SOURCE, "root = root.realpath" - assert_includes SOURCE, 'directory.join(".git").exist?' - assert_includes SOURCE, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)' - assert_includes SOURCE, "stat = directory.lstat" - assert_includes SOURCE, "Conversation sandbox escapes its root" + 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: /sandbox/#{conversation.id}' + refute_includes SOURCE, "Sandbox:" end def test_example_permission_order_denies_every_tool diff --git a/test/readme_test.rb b/test/readme_test.rb index dc5ad6f..2e1c938 100644 --- a/test/readme_test.rb +++ b/test/readme_test.rb @@ -48,23 +48,23 @@ class ReadmeTest < Minitest::Test assert_includes @readme, %(ref: "#{client_ref}") assert_includes @readme, "`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate" - assert_includes @readme, "pushing a `v*` tag does not guarantee publication" + 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, 'sandbox_directory = sandbox_directory_for(conversation)' - assert_includes @quickstart, 'directory: sandbox_directory.to_s' + 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"' - assert_includes @quickstart, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")' - assert_includes @quickstart, "root = root.realpath" - assert_includes @quickstart, 'directory.join(".git").exist?' - assert_includes @quickstart, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)' - assert_includes @quickstart, "stat = directory.lstat" - assert_includes @quickstart, "Conversation sandbox escapes its root" - assert_includes @readme, "must be outside every Git worktree" - assert_includes @readme, "same absolute path in the OpenCode server" + 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" diff --git a/test/release_workflow_test.rb b/test/release_workflow_test.rb index a1eb3c2..17435dd 100644 --- a/test/release_workflow_test.rb +++ b/test/release_workflow_test.rb @@ -64,12 +64,18 @@ class ReleaseWorkflowTest < Minitest::Test 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 -e" + assert_includes commands, "bundle exec ruby -ropencode/version" assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"' - assert_includes commands, 'client_gem="$client_dir/opencode-ruby-${client_version}.gem"' + 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 install --local "$client_gem" --no-document' - assert_includes commands, 'gem install --local "$rails_gem" --no-document' + 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