Finalize alpha9 release safeguards
This commit is contained in:
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
64
README.md
64
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user