diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c01849..645542f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,64 @@ on: - "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 gems load after install + 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" + 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(":")')" + export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}" + export GEM_PATH="${GEM_HOME}:${bundle_gem_path}" + mkdir -p "$GEM_HOME" + gem install --local "$client_gem" --no-document + gem install --local "$rails_gem" --no-document + 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: @@ -21,14 +78,4 @@ jobs: with: ruby-version: "4.0" 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 - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 697561d..12b71d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## 0.0.1.alpha8 - 2026-07-20 +## 0.0.1.alpha9 - 2026-07-20 ### Bumped -- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha8`, carrying the +- 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. @@ -16,18 +16,33 @@ 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 use a + realpath-validated per-conversation directory outside Git worktrees. 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 -- Test the supported runtime surface on Ruby 3.2, 3.3, 3.4, and 4.0. +- 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`. +## 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 diff --git a/Gemfile b/Gemfile index fc53998..db408a9 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,6 @@ source "https://rubygems.org" gem "opencode-ruby", git: "https://github.com/ajaynomics/opencode-ruby.git", - ref: "49a161632e6631d3605af5170de00c4688cfcedb" + ref: "65a44ca1502926d533e6b4b6692779fa39740218" gemspec diff --git a/README.md b/README.md index 9e00434..bb708be 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay ## Install -After both alpha8 gems are confirmed on RubyGems, pin the lockstep tuple: +After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple: ```ruby # Gemfile -gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine -gem "opencode-rails", "= 0.0.1.alpha8" +gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine +gem "opencode-rails", "= 0.0.1.alpha9" ``` Until publication is verified, validate this candidate checkout against the @@ -32,7 +32,7 @@ exact `opencode-ruby` source it was tested with: # Gemfile gem "opencode-ruby", git: "https://github.com/ajaynomics/opencode-ruby.git", - ref: "49a161632e6631d3605af5170de00c4688cfcedb" + ref: "65a44ca1502926d533e6b4b6692779fa39740218" gem "opencode-rails", path: "../opencode-rails" @@ -50,12 +50,15 @@ Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and 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.alpha8 +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. -`opencode-rails` 0.0.1.alpha8 is a release candidate and is not yet confirmed -published on RubyGems. The `release.yml` workflow is prepared for RubyGems +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 @@ -97,7 +100,11 @@ class GenerateResponseJob < ApplicationJob unless user_message.conversation_id == conversation.id raise ArgumentError, "User and assistant messages must belong to the same conversation" end - client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL")) + sandbox_directory = sandbox_directory_for(conversation) + client = Opencode::Client.new( + base_url: ENV.fetch("OPENCODE_URL"), + directory: sandbox_directory.to_s + ) session = Opencode::Session.new( conversation, @@ -127,21 +134,54 @@ class GenerateResponseJob < ApplicationJob private - def permission_rules_for(conversation) + def permission_rules_for(_conversation) [ - { permission: "bash", pattern: "*", action: "deny" }, - { permission: "external_directory", pattern: "*", action: "deny" }, - { permission: "edit", pattern: "*", action: "deny" }, - { - permission: "edit", - pattern: "data/sandbox/#{conversation.id}/**", - action: "allow" - } + { 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. + +`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`, diff --git a/examples/rails_integration.rb b/examples/rails_integration.rb index 66b981d..034cfca 100644 --- a/examples/rails_integration.rb +++ b/examples/rails_integration.rb @@ -76,11 +76,17 @@ class GenerateResponseJob < ApplicationJob unless user_message.conversation_id == conversation.id raise ArgumentError, "User and assistant messages must belong to the same conversation" end - client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL")) + sandbox_directory = sandbox_directory_for(conversation) + client = Opencode::Client.new( + base_url: ENV.fetch("OPENCODE_URL"), + directory: sandbox_directory.to_s + ) # Session: AR-coupled, row-locked, idempotent. ensure! creates the # OpenCode session if conversation.opencode_session_id is blank; # returns the existing id otherwise. + # Session applies permissions only when it creates a session. + # Recreate persisted sessions before deploying a directory or policy change. session = Opencode::Session.new( conversation, permissions_for: ->(record) { permission_rules_for(record) }, @@ -114,28 +120,50 @@ class GenerateResponseJob < ApplicationJob private - def permission_rules_for(conversation) + def permission_rules_for(_conversation) # Per-product permissions. The shape mirrors what # opencode-ruby's Client#create_session expects in `permissions:`. [ - { permission: "bash", pattern: "*", action: "deny" }, - { permission: "external_directory", pattern: "*", action: "deny" }, - { permission: "edit", pattern: "*", action: "deny" }, - { - permission: "edit", - pattern: "data/sandbox/#{conversation.id}/**", - action: "allow" - } + { permission: "*", pattern: "*", action: "deny" } ] 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/#{conversation.id} + Sandbox: #{sandbox_directory_for(conversation)} CONTEXT end end diff --git a/lib/opencode/rails_version.rb b/lib/opencode/rails_version.rb index e39d16b..f0e76a9 100644 --- a/lib/opencode/rails_version.rb +++ b/lib/opencode/rails_version.rb @@ -11,5 +11,5 @@ # We can't reuse the same constant from a second gem, so we use a # distinct, non-namespaced constant. module Opencode - RAILS_VERSION = "0.0.1.alpha8" + RAILS_VERSION = "0.0.1.alpha9" end diff --git a/lib/opencode/sandbox_file.rb b/lib/opencode/sandbox_file.rb index 717f8f6..64cb6d0 100644 --- a/lib/opencode/sandbox_file.rb +++ b/lib/opencode/sandbox_file.rb @@ -84,16 +84,19 @@ module Opencode def with_safe_file before = File.lstat(path) - raise UnsafeFileError, "Unsafe sandbox file: #{basename}" unless before.file? && !before.symlink? + 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 - File.open(path, "rb") do |file| + flags = safe_open_flags + File.open(path, flags, encoding: Encoding::BINARY) do |file| opened = file.stat - unless opened.file? && opened.dev == before.dev && opened.ino == before.ino + 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 @@ -102,8 +105,20 @@ module Opencode yield file end - rescue Errno::ENOENT, Errno::ELOOP => e + 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 diff --git a/opencode-rails.gemspec b/opencode-rails.gemspec index 0bf035c..2407684 100644 --- a/opencode-rails.gemspec +++ b/opencode-rails.gemspec @@ -37,7 +37,7 @@ Gem::Specification.new do |spec| # this gem builds on. During alpha both gems evolve in lockstep — we pin # exactly (= not ~>) so that consumers always pick the version this gem # was tested against. - spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha8" + 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 diff --git a/test/example_test.rb b/test/example_test.rb index 3efdbdc..405c9d5 100644 --- a/test/example_test.rb +++ b/test/example_test.rb @@ -37,8 +37,35 @@ class ExampleTest < Minitest::Test def test_example_uses_current_fail_closed_permission_rules refute_match(/\{\s*type:/, SOURCE) - assert_includes SOURCE, '{ permission: "bash", pattern: "*", action: "deny" }' - assert_includes SOURCE, '{ permission: "external_directory", pattern: "*", action: "deny" }' - assert_includes SOURCE, '{ permission: "edit", pattern: "*", action: "deny" }' + assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)' + assert_includes SOURCE, 'directory: sandbox_directory.to_s' + 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, "permissions only when it creates a session" + assert_match(/recreate persisted sessions/i, SOURCE) + refute_includes SOURCE, 'Sandbox: /sandbox/#{conversation.id}' + end + + def test_example_permission_order_denies_every_tool + body = SOURCE[/def permission_rules_for\(_conversation\)\n(?.*?)^ 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 diff --git a/test/opencode/sandbox_file_test.rb b/test/opencode/sandbox_file_test.rb index 554ab60..2d04de6 100644 --- a/test/opencode/sandbox_file_test.rb +++ b/test/opencode/sandbox_file_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "fileutils" +require "socket" require "tmpdir" require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for @@ -76,6 +77,34 @@ class Opencode::SandboxFileTest < Minitest::Test 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 @@ -98,6 +127,76 @@ class Opencode::SandboxFileTest < Minitest::Test 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( diff --git a/test/readme_test.rb b/test/readme_test.rb index 70ebe7a..dc5ad6f 100644 --- a/test/readme_test.rb +++ b/test/readme_test.rb @@ -53,11 +53,21 @@ class ReadmeTest < Minitest::Test def test_quickstart_uses_current_fail_closed_permission_rules refute_match(/\{\s*type:/, @quickstart) - assert_includes @quickstart, '{ permission: "bash", pattern: "*", action: "deny" }' - assert_includes @quickstart, '{ permission: "external_directory", pattern: "*", action: "deny" }' - assert_includes @quickstart, '{ permission: "edit", pattern: "*", action: "deny" }' - assert_includes @quickstart, 'permission: "edit",' - assert_includes @quickstart, 'action: "allow"' + assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)' + assert_includes @quickstart, 'directory: sandbox_directory.to_s' + 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" + 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 diff --git a/test/release_workflow_test.rb b/test/release_workflow_test.rb index 86616f6..a1eb3c2 100644 --- a/test/release_workflow_test.rb +++ b/test/release_workflow_test.rb @@ -17,6 +17,10 @@ class ReleaseWorkflowTest < Minitest::Test 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 @@ -32,33 +36,41 @@ class ReleaseWorkflowTest < Minitest::Test 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.fetch("run", "").match?(/\bgem\s+push\b/) } + refute steps.any? { |step| step.key?("run") } end def test_release_tag_must_match_the_gem_version_before_publish - steps = push_job.fetch("steps") - preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" } - publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION } + preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" } - refute_nil preflight_index - refute_nil publish_index - assert_operator preflight_index, :<, publish_index - - preflight = steps.fetch(preflight_index) + 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_release_runs_the_test_suite_before_publish - steps = push_job.fetch("steps") - test_index = steps.index { |step| step["name"] == "Run tests" } - publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION } + def test_verification_job_has_read_only_credentials + assert_equal({ "contents" => "read" }, verify_job.fetch("permissions")) - refute_nil test_index - refute_nil publish_index - assert_operator test_index, :<, publish_index - assert_equal "bundle exec rake test", steps.fetch(test_index).fetch("run") + 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 -e" + 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, '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.loaded_specs.fetch(name).full_gem_path" + assert_includes commands, "ruby -ropencode-rails" end end diff --git a/test/workflow_contract_test.rb b/test/workflow_contract_test.rb index 428d3ce..11a6cea 100644 --- a/test/workflow_contract_test.rb +++ b/test/workflow_contract_test.rb @@ -27,7 +27,7 @@ class WorkflowContractTest < Minitest::Test workflow_uses(workflow) end - assert_equal 5, action_uses.length + assert_equal 7, action_uses.length action_uses.each do |action_use| action, separator, revision = action_use.rpartition("@")