Finalize alpha9 release safeguards

This commit is contained in:
2026-07-20 20:51:01 -07:00
parent 427bd47648
commit 9937e45f5e
7 changed files with 68 additions and 110 deletions

View File

@@ -40,19 +40,19 @@ jobs:
- name: Build gem - name: Build gem
run: gem build opencode-rails.gemspec run: gem build opencode-rails.gemspec
- name: Verify gems load after install - name: Verify published client and installed gems
run: | run: |
client_dir="$(bundle show opencode-ruby)" client_version="$(bundle exec ruby -ropencode/version -e 'print Opencode::VERSION')"
(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" 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_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}:${bundle_gem_path}" export GEM_PATH="${GEM_HOME}"
mkdir -p "$GEM_HOME" mkdir -p "$GEM_HOME"
gem install --local "$client_gem" --no-document gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org
gem install --local "$rails_gem" --no-document gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org
ruby -ropencode-rails -e ' ruby -ropencode-rails -e '
root = File.realpath(ENV.fetch("GEM_HOME")) root = File.realpath(ENV.fetch("GEM_HOME"))
%w[opencode-ruby opencode-rails].each do |name| %w[opencode-ruby opencode-rails].each do |name|

View File

@@ -19,9 +19,10 @@
- Reject hard-linked sandbox artifacts and open files with nonblocking, - Reject hard-linked sandbox artifacts and open files with nonblocking,
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
closed without hanging artifact collection. closed without hanging artifact collection.
- Make the reference integration deny every OpenCode tool by default and use a - Make the reference integration deny every OpenCode tool by default and
realpath-validated per-conversation directory outside Git worktrees. Hosts require project-local OpenCode configuration to be disabled at server
must add their own OS or container boundary before allowing filesystem tools. startup. Hosts must add their own OS or container boundary before allowing
filesystem tools.
- Load every runtime standard library explicitly and align the shipped - Load every runtime standard library explicitly and align the shipped
permissions, observer, Turn, prompt, and instrumentation examples with the permissions, observer, Turn, prompt, and instrumentation examples with the
actual APIs. actual APIs.
@@ -34,6 +35,8 @@
use Ruby 4.0 for release builds. use Ruby 4.0 for release builds.
- Fail the trusted-publishing job before release when the pushed tag does not - Fail the trusted-publishing job before release when the pushed tag does not
match `Opencode::RAILS_VERSION`. 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 ## 0.0.1.alpha8 - 2026-07-20

View File

@@ -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 The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published. `opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed `opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
published on RubyGems. The published on RubyGems. Because the gem has no RubyGems entry yet, create a
`release.yml` workflow is prepared for RubyGems pending trusted publisher for gem `opencode-rails`, repository owner
trusted publishing, but the gem's trusted publisher must be registered and `ajaynomics`, repository `opencode-rails`, workflow `release.yml`, and
verified for that workflow and its `release` environment. Until the registry environment `release`. RubyGems converts it to a normal trusted publisher after
result is verified, pushing a `v*` tag does not guarantee publication. Trusted the first successful push. The workflow also refuses to publish until the
publishing does not require a long-lived RubyGems API key. 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
@@ -100,10 +103,10 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation" raise ArgumentError, "User and assistant messages must belong to the same conversation"
end end
sandbox_directory = sandbox_directory_for(conversation) working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new( client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"), base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s directory: working_directory
) )
session = Opencode::Session.new( session = Opencode::Session.new(
@@ -139,43 +142,20 @@ class GenerateResponseJob < ApplicationJob
{ permission: "*", pattern: "*", action: "deny" } { permission: "*", pattern: "*", action: "deny" }
] ]
end 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 end
``` ```
`OPENCODE_SANDBOX_ROOT` must be outside every Git worktree and mounted at the Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
same absolute path in the OpenCode server. OpenCode treats an entire detected project configuration, plugins, and instructions while opening a directory,
worktree as internal, so a directory inside the application repository is not before session permissions exist; the wildcard deny rule cannot constrain that
an `external_directory` boundary. This quickstart intentionally grants no bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
filesystem tools: OpenCode permissions alone are not an OS sandbox. Add mounted at the same absolute path in Rails and OpenCode. Treat the server's
product-specific allows only when the OpenCode process also has an independent global configuration, plugins, and instructions as privileged administrator
container or operating-system boundary around the conversation directory. 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 `Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions deploying a directory or permission-policy change, recreate persisted sessions

View File

@@ -28,6 +28,9 @@
# 4. Permissions builder: per-product session permission rules. # 4. Permissions builder: per-product session permission rules.
# #
# Contract-tested reference. Copy what you need and adapt it to your domain. # 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 # 1. config/initializers/opencode.rb
@@ -76,10 +79,10 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation" raise ArgumentError, "User and assistant messages must belong to the same conversation"
end end
sandbox_directory = sandbox_directory_for(conversation) working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new( client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"), base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s directory: working_directory
) )
# Session: AR-coupled, row-locked, idempotent. ensure! creates the # Session: AR-coupled, row-locked, idempotent. ensure! creates the
@@ -128,42 +131,12 @@ class GenerateResponseJob < ApplicationJob
] ]
end 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) 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 <<~CONTEXT
User: #{conversation.user.name} User: #{conversation.user.name}
Conversation: #{conversation.id} Conversation: #{conversation.id}
Sandbox: #{sandbox_directory_for(conversation)}
CONTEXT CONTEXT
end end
end end

View File

@@ -37,19 +37,15 @@ class ExampleTest < Minitest::Test
def test_example_uses_current_fail_closed_permission_rules def test_example_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, SOURCE) refute_match(/\{\s*type:/, SOURCE)
assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)' assert_includes SOURCE, 'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes SOURCE, 'directory: sandbox_directory.to_s' assert_includes SOURCE, "directory: working_directory"
assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }' assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes SOURCE, 'action: "allow"' refute_includes SOURCE, 'action: "allow"'
assert_includes SOURCE, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")' assert_includes SOURCE, "OPENCODE_DISABLE_PROJECT_CONFIG=1"
assert_includes SOURCE, "root = root.realpath" refute_includes SOURCE, "ConversationSandbox"
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_includes SOURCE, "permissions only when it creates a session"
assert_match(/recreate persisted sessions/i, SOURCE) assert_match(/recreate persisted sessions/i, SOURCE)
refute_includes SOURCE, 'Sandbox: /sandbox/#{conversation.id}' refute_includes SOURCE, "Sandbox:"
end end
def test_example_permission_order_denies_every_tool def test_example_permission_order_denies_every_tool

View File

@@ -48,23 +48,23 @@ class ReadmeTest < Minitest::Test
assert_includes @readme, %(ref: "#{client_ref}") assert_includes @readme, %(ref: "#{client_ref}")
assert_includes @readme, assert_includes @readme,
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate" "`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 end
def test_quickstart_uses_current_fail_closed_permission_rules def test_quickstart_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, @quickstart) refute_match(/\{\s*type:/, @quickstart)
assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)' assert_includes @quickstart,
assert_includes @quickstart, 'directory: sandbox_directory.to_s' 'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes @quickstart, "directory: working_directory"
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }' assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes @quickstart, 'action: "allow"' refute_includes @quickstart, 'action: "allow"'
assert_includes @quickstart, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")' refute_includes @quickstart, "ConversationSandbox"
assert_includes @quickstart, "root = root.realpath" assert_includes @readme, "same absolute path in Rails and OpenCode"
assert_includes @quickstart, 'directory.join(".git").exist?' assert_includes @readme, "`OPENCODE_DISABLE_PROJECT_CONFIG=1`"
assert_includes @quickstart, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)' assert_includes @readme, "before session permissions exist"
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_match(/intentionally grants no\s+filesystem tools/, @readme)
assert_includes @readme, "permissions only when it creates a session" assert_includes @readme, "permissions only when it creates a session"
assert_includes @readme, "recreate persisted sessions" assert_includes @readme, "recreate persisted sessions"

View File

@@ -64,12 +64,18 @@ class ReleaseWorkflowTest < Minitest::Test
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n") commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
assert_includes commands, "bundle exec rake test" assert_includes commands, "bundle exec rake test"
assert_includes commands, "gem build opencode-rails.gemspec" 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, '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, 'rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version'
assert_includes commands, 'gem install --local "$client_gem" --no-document' assert_includes commands, 'gem fetch opencode-ruby --version "$client_version" --prerelease'
assert_includes commands, 'gem install --local "$rails_gem" --no-document' 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, "Gem.loaded_specs.fetch(name).full_gem_path"
assert_includes commands, "ruby -ropencode-rails" assert_includes commands, "ruby -ropencode-rails"
end end