Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.2.3 (2026-09-23)

- Preserve provider failures when macOS reports a transient permission error for an exiting process group; verify that the group is gone before ignoring cleanup errors.

## 0.2.2 (2026-09-23)

- Clear the exposed PID after an already-exited CLI has been reaped, preventing stale process diagnostics.

## 0.2.1 (2026-09-23)

- Expose the active CLI PID through `AgentThread#pid` for worker recovery diagnostics.

## 0.2.0 (2026-09-23)

- Forward ordered `local_image` inputs through the public thread API; reject unsupported input types and invalid local paths instead of silently discarding them.
- Add `ignore_user_config`, `ignore_rules`, and `ephemeral` thread options; use the documented `--json` CLI flag.
- Honor explicit environment replacement without inheriting parent secrets.
- Terminate and reap CLI process groups on cancellation or consumer errors, with concurrent interruption serialized.
- Raise on streams that end without a completed turn instead of returning partial answers as success.

## 0.1.5 (2026-06-17)

- Include structured Codex CLI error events in non-zero exit errors, so
Expand Down
40 changes: 34 additions & 6 deletions lib/codex_sdk/agent_thread.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def run(input, turn_options: TurnOptions.new)

# Streaming run: yields each event to the block as it arrives.
def run_streamed(input, turn_options: TurnOptions.new, &block)
prompt = normalize_input(input)
prompt, images = normalize_input(input)
completed = false

output_schema_path = nil
output_schema_path = write_output_schema(turn_options.output_schema) if turn_options.output_schema
Expand All @@ -50,19 +51,27 @@ def run_streamed(input, turn_options: TurnOptions.new, &block)

@exec.run(
prompt,
images: images,
resume_thread_id: @id,
output_schema_path: output_schema_path
) do |event|
# Capture thread ID from first event
@id = event.thread_id if event.is_a?(Events::ThreadStarted)
completed = true if event.is_a?(Events::TurnCompleted)

block.call(event)
end
raise Error, "Codex stream ended without turn completion" unless completed
ensure
@context_snapshot = @exec&.context_snapshot
cleanup_output_schema(output_schema_path)
end

# PID of the running CLI, for external lifecycle diagnostics.
def pid
@exec&.pid
end

# Interrupt the running subprocess.
def interrupt
@exec&.interrupt
Expand All @@ -73,13 +82,32 @@ def interrupt
def normalize_input(input)
case input
when String
input
[input, []]
when Array
input.filter_map do |entry|
entry[:text] if entry[:type] == "text"
end.join("\n\n")
texts = []
images = []
input.each do |entry|
raise ArgumentError, "Unsupported input entry" unless entry.is_a?(Hash)

case entry[:type]
when "text"
raise ArgumentError, "Text input must be a string" unless entry[:text].is_a?(String)

texts << entry[:text]
when "local_image"
path = entry[:path]
unless path.is_a?(String) && path.start_with?(File::SEPARATOR) && File.file?(path) && File.readable?(path)
raise ArgumentError, "Local image must be an absolute readable file path"
end

images << path
else
raise ArgumentError, "Unsupported input type: #{entry[:type].inspect}"
end
end
[texts.join("\n\n"), images]
else
input.to_s
raise ArgumentError, "Unsupported input: expected string or array"
end
end

Expand Down
42 changes: 31 additions & 11 deletions lib/codex_sdk/exec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

module CodexSDK
# Internal: manages the codex CLI subprocess.
# Spawns `codex exec --experimental-json`, writes prompt to stdin,
# Spawns `codex exec --json`, writes prompt to stdin,
# reads JSONL events from stdout.
class Exec
SHUTDOWN_TIMEOUT = 10 # seconds to wait after SIGTERM before SIGKILL
Expand All @@ -32,7 +32,9 @@ def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &blo
@context_snapshot = nil
diagnostics = []

@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *args)
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *args, unsetenv_others: !@options.env.nil?,
pgroup: true)
@pid = @wait_thread.pid

# Write prompt and close stdin (one-shot, matching TypeScript SDK)
@stdin.write(prompt.to_s)
Expand Down Expand Up @@ -85,27 +87,41 @@ def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &blo
# Sends SIGTERM to the subprocess, waits, then SIGKILL if needed.
def interrupt
@mutex.synchronize do
return unless @wait_thread&.alive?
return unless @pid

begin
Process.kill("TERM", @wait_thread.pid)
signal_group("TERM")
rescue Errno::ESRCH
@wait_thread.value
@pid = nil
return
end

# Wait for graceful shutdown
unless wait_for_exit(SHUTDOWN_TIMEOUT)
begin
Process.kill("KILL", @wait_thread.pid)
rescue Errno::ESRCH
# already gone
end
wait_for_exit(SHUTDOWN_TIMEOUT)
begin
# Also stop descendants that outlive the immediate CLI child.
signal_group("KILL")
rescue Errno::ESRCH
# already gone
end
@wait_thread.value
@pid = nil
end
end

private

def signal_group(signal)
Process.kill(signal, -@pid)
rescue Errno::EPERM
# macOS can report EPERM while the last group member is exiting.
# Only treat it as gone when a subsequent group probe confirms ESRCH.
@wait_thread.join(0.1)
Process.kill(0, -@pid)
raise
end

def capture_diagnostic(event, diagnostics)
message =
case event
Expand Down Expand Up @@ -136,7 +152,7 @@ def build_args(resume_thread_id: nil, images: [], output_schema_path: nil)
codex_path = @options.codex_path || find_codex_path
args = [codex_path]
args << "--search" if @thread_options.web_search
args.concat(["exec", "--experimental-json"])
args.concat(["exec", "--json"])

# Global config overrides
args.concat(ConfigSerializer.to_flags(@options.config)) if @options.config.any?
Expand All @@ -153,6 +169,9 @@ def build_args(resume_thread_id: nil, images: [], output_schema_path: nil)
args.concat(["--cd", to.working_directory]) if to.working_directory
args << "--dangerously-bypass-approvals-and-sandbox" if to.dangerously_bypass_approvals_and_sandbox
args << "--skip-git-repo-check" if to.skip_git_repo_check
args << "--ignore-user-config" if to.ignore_user_config
args << "--ignore-rules" if to.ignore_rules
args << "--ephemeral" if to.ephemeral

to.additional_directories.each { |dir| args.concat(["--add-dir", dir]) }

Expand Down Expand Up @@ -227,6 +246,7 @@ def wait_for_exit(timeout)
end

def cleanup
interrupt
@stdin&.close unless @stdin&.closed?
@stdout&.close unless @stdout&.closed?
@stderr&.close unless @stderr&.closed?
Expand Down
10 changes: 8 additions & 2 deletions lib/codex_sdk/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ def inspect
:network_access,
:web_search,
:additional_directories,
:skip_git_repo_check
:skip_git_repo_check,
:ignore_user_config,
:ignore_rules,
:ephemeral
) do
def initialize(
model: nil,
Expand All @@ -43,7 +46,10 @@ def initialize(
network_access: nil,
web_search: nil,
additional_directories: [],
skip_git_repo_check: false
skip_git_repo_check: false,
ignore_user_config: false,
ignore_rules: false,
ephemeral: false
)
super
end
Expand Down
2 changes: 1 addition & 1 deletion lib/codex_sdk/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module CodexSDK
VERSION = "0.1.5"
VERSION = "0.2.3"
end
6 changes: 4 additions & 2 deletions spec/codex_sdk/agent_thread_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ def stub_exec_run(events)
end

it "captures thread_id from ThreadStarted event" do
events = [CodexSDK::Events::ThreadStarted.new(thread_id: "thread_xyz")]
events = [CodexSDK::Events::ThreadStarted.new(thread_id: "thread_xyz"),
CodexSDK::Events::TurnCompleted.new(usage: nil)]
stub_exec_run(events)

thread = described_class.new(options, thread_options: thread_options)
Expand All @@ -119,6 +120,7 @@ def stub_exec_run(events)
allow(CodexSDK::Exec).to receive(:new).and_return(exec)
allow(exec).to receive(:run) do |_prompt, **_kwargs, &block|
block.call(CodexSDK::Events::ThreadStarted.new(thread_id: "thread_xyz"))
block.call(CodexSDK::Events::TurnCompleted.new(usage: nil))
end

thread = described_class.new(options, thread_options: thread_options)
Expand All @@ -132,7 +134,7 @@ def stub_exec_run(events)
it "delegates to exec" do
exec = instance_double(CodexSDK::Exec, interrupt: nil, context_snapshot: nil)
allow(CodexSDK::Exec).to receive(:new).and_return(exec)
allow(exec).to receive(:run)
allow(exec).to receive(:run).and_yield(CodexSDK::Events::TurnCompleted.new(usage: nil))

thread = described_class.new(options, thread_options: thread_options)
thread.run_streamed("test") { |_event| nil }
Expand Down
142 changes: 142 additions & 0 deletions spec/codex_sdk/analysis_compatibility_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# frozen_string_literal: true

require "spec_helper"
require "tmpdir"
require "timeout"

RSpec.describe "Analysis CLI compatibility" do
around do |example|
Dir.mktmpdir("codex-sdk-test") do |dir|
@dir = dir
@cli = File.join(dir, "fake-codex")
example.run
end
end

def cli(body)
File.write(@cli, "#!#{RbConfig.ruby}\nrequire 'json'\n#{body}")
File.chmod(0o700, @cli)
end

def client(env: {})
CodexSDK::Client.new(codex_path: @cli, env: env)
end

it "forwards ordered images and text through the public API with isolation flags" do
cli(<<~CODE)
puts JSON.generate(type: 'item.completed', item: {id: 'a', type: 'agent_message', text: JSON.generate(args: ARGV, prompt: STDIN.read)})
puts JSON.generate(type: 'turn.completed', usage: {})
CODE
image = File.join(@dir, "frame.png")
File.write(image, "image fixture")
thread = client.start_thread(ignore_user_config: true, ignore_rules: true, ephemeral: true)
turn = thread.run([{ type: "text", text: "first" }, { type: "local_image", path: image },
{ type: "text", text: "second" }])
expect(thread.pid).to be_nil
result = JSON.parse(turn.final_response)
expect(result["prompt"]).to eq("first\n\nsecond")
expect(result["args"]).to eq(["exec", "--json", "--ignore-user-config", "--ignore-rules", "--ephemeral", "--image",
image])
end

it "rejects unsupported input instead of silently discarding it" do
expect do
client.start_thread.run([{ type: "video", path: "/tmp/clip.mp4" }])
end.to raise_error(ArgumentError, /Unsupported/)
end

it "does not inherit credentials with explicit environment replacement" do
cli(<<~CODE)
puts JSON.generate(type: 'item.completed', item: {id: 'a', type: 'agent_message', text: ENV.fetch('CODEX_TEST_SECRET', 'absent')})
puts JSON.generate(type: 'turn.completed', usage: {})
CODE
original = ENV.fetch("CODEX_TEST_SECRET", nil)
ENV["CODEX_TEST_SECRET"] = "must-not-leak"
expect(client.start_thread.run("test").final_response).to eq("absent")
ensure
ENV["CODEX_TEST_SECRET"] = original
end

it "rejects a partial answer without completion even on exit zero" do
cli("puts JSON.generate(type: 'item.completed', item: {id: 'a', type: 'agent_message', text: 'partial'})")
expect { client.start_thread.run("test") }.to raise_error(CodexSDK::Error, /complet/)
end

it "stops and reaps the subprocess when an event consumer raises" do
cli(<<~CODE)
STDOUT.sync = true
puts JSON.generate(type: 'thread.started', thread_id: Process.pid.to_s)
sleep 60
CODE
child = nil
expect do
Timeout.timeout(5) do
client.start_thread.run_streamed("test") do |event|
child = event.thread_id.to_i
raise "consumer failed"
end
end
end.to raise_error(RuntimeError, "consumer failed")
expect { Process.kill(0, child) }.to raise_error(Errno::ESRCH)
ensure
Process.kill("KILL", child) if child && process_alive?(child)
end

it "interrupts a live process safely from competing callers" do
cli(<<~CODE)
STDOUT.sync = true
puts JSON.generate(type: 'thread.started', thread_id: Process.pid.to_s)
sleep 60
CODE
started = Queue.new
thread = client.start_thread
runner = Thread.new do
thread.run_streamed("test") { |event| started << event.thread_id.to_i }
rescue CodexSDK::Error => e
e
end
child = Timeout.timeout(5) { started.pop }
expect(thread.pid).to eq(child)
cancellers = 2.times.map { Thread.new { thread.interrupt } }
cancellers.each(&:join)
expect(runner.join(5)).to eq(runner)
expect(runner.value).to be_a(CodexSDK::ExecError)
expect { Process.kill(0, child) }.to raise_error(Errno::ESRCH)
ensure
thread&.interrupt
runner&.join(5)
end

it "kills descendants even when the CLI exits before them" do
cli(<<~CODE)
STDOUT.sync = true
child = fork { trap('TERM') {}; sleep 60 }
puts JSON.generate(type: 'thread.started', thread_id: child.to_s)
exit 0
CODE
started = Queue.new
thread = client.start_thread
runner = Thread.new do
thread.run_streamed("test") { |event| started << event.thread_id.to_i }
rescue CodexSDK::Error => e
e
end
child = Timeout.timeout(5) { started.pop }
sleep 0.1
thread.interrupt
expect(runner.join(5)).to eq(runner)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
sleep 0.05 while process_alive?(child) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
expect(process_alive?(child)).to be(false)
ensure
thread&.interrupt
runner&.join(5)
end

def process_alive?(pid)
Process.kill(0, pid)
true
rescue Errno::ESRCH
false
end
end
Loading
Loading