Prepare repaired alpha9 release

This commit is contained in:
2026-07-20 20:04:59 -07:00
parent b02bcc74ef
commit 427bd47648
13 changed files with 367 additions and 74 deletions

View File

@@ -6,7 +6,64 @@ on:
- "v*" - "v*"
jobs: 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: push:
needs: verify
if: ${{ github.server_url == 'https://github.com' }} if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -21,14 +78,4 @@ jobs:
with: with:
ruby-version: "4.0" ruby-version: "4.0"
bundler-cache: true 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 - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -1,10 +1,10 @@
# Changelog # Changelog
## 0.0.1.alpha8 - 2026-07-20 ## 0.0.1.alpha9 - 2026-07-20
### Bumped ### 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- hardened SSE framing parser while retaining the alpha7 subscribe-before-
prompt and at-most-once reconnect contract. prompt and at-most-once reconnect contract.
@@ -16,18 +16,33 @@
destination symlinks without following them, and validate bounded reads from destination symlinks without following them, and validate bounded reads from
the opened sandbox file. Upload copying now requires a traversable the opened sandbox file. Upload copying now requires a traversable
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it. `/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 - 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.
### Changed ### 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 - Pin every third-party CI and release action to an exact reviewed commit and
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`.
## 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 ## 0.0.1.alpha7 - 2026-07-18
### Fixed ### Fixed

View File

@@ -2,6 +2,6 @@ source "https://rubygems.org"
gem "opencode-ruby", gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git", git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "49a161632e6631d3605af5170de00c4688cfcedb" ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gemspec gemspec

View File

@@ -17,12 +17,12 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
## Install ## 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 ```ruby
# Gemfile # Gemfile
gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha8" gem "opencode-rails", "= 0.0.1.alpha9"
``` ```
Until publication is verified, validate this candidate checkout against the Until publication is verified, validate this candidate checkout against the
@@ -32,7 +32,7 @@ exact `opencode-ruby` source it was tested with:
# Gemfile # Gemfile
gem "opencode-ruby", gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git", git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "49a161632e6631d3605af5170de00c4688cfcedb" ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails", gem "opencode-rails",
path: "../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 provide one of those traversable descriptor filesystems; unsupported platforms
fail closed before writing. 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 retains the subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again, while hardening SSE framing. 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 The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
published on RubyGems. The `release.yml` workflow is prepared for RubyGems `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 trusted publishing, but the gem's trusted publisher must be registered and
verified for that workflow and its `release` environment. Until the registry verified for that workflow and its `release` environment. Until the registry
result is verified, pushing a `v*` tag does not guarantee publication. Trusted 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 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
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( session = Opencode::Session.new(
conversation, conversation,
@@ -127,21 +134,54 @@ class GenerateResponseJob < ApplicationJob
private private
def permission_rules_for(conversation) def permission_rules_for(_conversation)
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" }
{ permission: "external_directory", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
{
permission: "edit",
pattern: "data/sandbox/#{conversation.id}/**",
action: "allow"
}
] ]
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
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`, The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`, `#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,

View File

@@ -76,11 +76,17 @@ 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
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 # 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) },
@@ -114,28 +120,50 @@ 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:`.
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" }
{ permission: "external_directory", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
{
permission: "edit",
pattern: "data/sandbox/#{conversation.id}/**",
action: "allow"
}
] ]
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/#{conversation.id} Sandbox: #{sandbox_directory_for(conversation)}
CONTEXT CONTEXT
end end
end end

View File

@@ -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.alpha8" RAILS_VERSION = "0.0.1.alpha9"
end end

View File

@@ -84,16 +84,19 @@ module Opencode
def with_safe_file def with_safe_file
before = File.lstat(path) 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 resolved = Pathname.new(path).realpath.to_s
unless resolved.start_with?(sandbox_prefix) unless resolved.start_with?(sandbox_prefix)
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}" raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
end end
File.open(path, "rb") do |file| flags = safe_open_flags
File.open(path, flags, encoding: Encoding::BINARY) do |file|
opened = file.stat 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}" raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
end end
if opened.size > @max_bytes if opened.size > @max_bytes
@@ -102,8 +105,20 @@ module Opencode
yield file yield file
end end
rescue Errno::ENOENT, Errno::ELOOP => e rescue SystemCallError => e
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}" raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
end 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

View File

@@ -37,7 +37,7 @@ 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.alpha8" spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
spec.add_runtime_dependency "marcel", "~> 1.0" 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

View File

@@ -37,8 +37,35 @@ 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, '{ permission: "bash", pattern: "*", action: "deny" }' assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes SOURCE, '{ permission: "external_directory", pattern: "*", action: "deny" }' assert_includes SOURCE, 'directory: sandbox_directory.to_s'
assert_includes SOURCE, '{ permission: "edit", pattern: "*", action: "deny" }' 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(?<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
end end

View File

@@ -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
@@ -76,6 +77,34 @@ class Opencode::SandboxFileTest < Minitest::Test
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content } assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end 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 def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
file = Opencode::SandboxFile.new( file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20 path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
@@ -98,6 +127,76 @@ class Opencode::SandboxFileTest < Minitest::Test
end end
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 def test_content_returns_binary_empty_string_for_a_zero_byte_file
File.write(@path, "") File.write(@path, "")
file = Opencode::SandboxFile.new( file = Opencode::SandboxFile.new(

View File

@@ -53,11 +53,21 @@ class ReadmeTest < Minitest::Test
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, '{ permission: "bash", pattern: "*", action: "deny" }' assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes @quickstart, '{ permission: "external_directory", pattern: "*", action: "deny" }' assert_includes @quickstart, 'directory: sandbox_directory.to_s'
assert_includes @quickstart, '{ permission: "edit", pattern: "*", action: "deny" }' assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
assert_includes @quickstart, 'permission: "edit",' refute_includes @quickstart, 'action: "allow"'
assert_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 end
def test_quickstart_uses_the_enqueued_user_message def test_quickstart_uses_the_enqueued_user_message

View File

@@ -17,6 +17,10 @@ class ReleaseWorkflowTest < Minitest::Test
workflow.fetch("jobs").fetch("push") workflow.fetch("jobs").fetch("push")
end end
def verify_job
workflow.fetch("jobs").fetch("verify")
end
def test_release_job_is_inert_on_non_github_runners def test_release_job_is_inert_on_non_github_runners
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if") assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
end end
@@ -32,33 +36,41 @@ class ReleaseWorkflowTest < Minitest::Test
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION } setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
assert_equal "4.0", setup_ruby.dig("with", "ruby-version") 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_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/) } 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 end
def test_release_tag_must_match_the_gem_version_before_publish def test_release_tag_must_match_the_gem_version_before_publish
steps = push_job.fetch("steps") preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" }
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
refute_nil preflight_index refute_nil preflight
refute_nil publish_index
assert_operator preflight_index, :<, publish_index
preflight = steps.fetch(preflight_index)
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG") assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected" assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
end end
def test_release_runs_the_test_suite_before_publish def test_verification_job_has_read_only_credentials
steps = push_job.fetch("steps") assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
test_index = steps.index { |step| step["name"] == "Run tests" }
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
refute_nil test_index checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
refute_nil publish_index assert_equal false, checkout.dig("with", "persist-credentials")
assert_operator test_index, :<, publish_index end
assert_equal "bundle exec rake test", steps.fetch(test_index).fetch("run")
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
end end

View File

@@ -27,7 +27,7 @@ class WorkflowContractTest < Minitest::Test
workflow_uses(workflow) workflow_uses(workflow)
end end
assert_equal 5, action_uses.length assert_equal 7, action_uses.length
action_uses.each do |action_use| action_uses.each do |action_use|
action, separator, revision = action_use.rpartition("@") action, separator, revision = action_use.rpartition("@")