From 163b914d398bc950cac992f19d652a1a2725c8d6 Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Wed, 23 Sep 2026 16:46:58 +0300 Subject: [PATCH 1/5] feat: support isolated image analysis through Codex CLI --- CHANGELOG.md | 8 ++ lib/codex_sdk/agent_thread.rb | 35 +++++- lib/codex_sdk/exec.rb | 30 +++-- lib/codex_sdk/options.rb | 10 +- lib/codex_sdk/version.rb | 2 +- spec/codex_sdk/agent_thread_spec.rb | 6 +- spec/codex_sdk/analysis_compatibility_spec.rb | 114 ++++++++++++++++++ spec/codex_sdk/exec_spec.rb | 10 +- 8 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 spec/codex_sdk/analysis_compatibility_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 443a763..2a9484a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/lib/codex_sdk/agent_thread.rb b/lib/codex_sdk/agent_thread.rb index cae0675..fdb91e4 100644 --- a/lib/codex_sdk/agent_thread.rb +++ b/lib/codex_sdk/agent_thread.rb @@ -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 @@ -50,14 +51,17 @@ 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) @@ -73,13 +77,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 diff --git a/lib/codex_sdk/exec.rb b/lib/codex_sdk/exec.rb index cb2b0a9..8b8bf15 100644 --- a/lib/codex_sdk/exec.rb +++ b/lib/codex_sdk/exec.rb @@ -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 @@ -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) @@ -85,22 +87,24 @@ 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) + Process.kill("TERM", -@pid) rescue Errno::ESRCH 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. + Process.kill("KILL", -@pid) + rescue Errno::ESRCH + # already gone end + @wait_thread.value + @pid = nil end end @@ -136,7 +140,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? @@ -153,6 +157,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]) } @@ -227,6 +234,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? diff --git a/lib/codex_sdk/options.rb b/lib/codex_sdk/options.rb index d168f95..7fb08e5 100644 --- a/lib/codex_sdk/options.rb +++ b/lib/codex_sdk/options.rb @@ -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, @@ -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 diff --git a/lib/codex_sdk/version.rb b/lib/codex_sdk/version.rb index de996a2..903b539 100644 --- a/lib/codex_sdk/version.rb +++ b/lib/codex_sdk/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodexSDK - VERSION = "0.1.5" + VERSION = "0.2.0" end diff --git a/spec/codex_sdk/agent_thread_spec.rb b/spec/codex_sdk/agent_thread_spec.rb index 87f2623..8fe7e36 100644 --- a/spec/codex_sdk/agent_thread_spec.rb +++ b/spec/codex_sdk/agent_thread_spec.rb @@ -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) @@ -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) @@ -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 } diff --git a/spec/codex_sdk/analysis_compatibility_spec.rb b/spec/codex_sdk/analysis_compatibility_spec.rb new file mode 100644 index 0000000..613b34d --- /dev/null +++ b/spec/codex_sdk/analysis_compatibility_spec.rb @@ -0,0 +1,114 @@ +# 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" }]) + 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 } + 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 + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end +end diff --git a/spec/codex_sdk/exec_spec.rb b/spec/codex_sdk/exec_spec.rb index 8a3a33c..da3f0df 100644 --- a/spec/codex_sdk/exec_spec.rb +++ b/spec/codex_sdk/exec_spec.rb @@ -114,8 +114,9 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) anything, "/usr/bin/codex", "exec", - "--experimental-json", - "--dangerously-bypass-approvals-and-sandbox" + "--json", + "--dangerously-bypass-approvals-and-sandbox", + unsetenv_others: false, pgroup: true ) end @@ -132,7 +133,8 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) "/usr/bin/codex", "--search", "exec", - "--experimental-json" + "--json", + unsetenv_others: false, pgroup: true ) end @@ -176,7 +178,7 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) exec.interrupt - expect(Process).to have_received(:kill).with("TERM", 12_345) + expect(Process).to have_received(:kill).with("TERM", -12_345) runner.join(1) end end From 691928bd3d0a5571ae0bc362fb659b82e150d73c Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Wed, 23 Sep 2026 17:01:18 +0300 Subject: [PATCH 2/5] fix: expose running CLI pid for recovery diagnostics --- CHANGELOG.md | 4 ++++ lib/codex_sdk/agent_thread.rb | 5 +++++ lib/codex_sdk/version.rb | 2 +- spec/codex_sdk/analysis_compatibility_spec.rb | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9484a..2b8b0c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. diff --git a/lib/codex_sdk/agent_thread.rb b/lib/codex_sdk/agent_thread.rb index fdb91e4..664cf0d 100644 --- a/lib/codex_sdk/agent_thread.rb +++ b/lib/codex_sdk/agent_thread.rb @@ -67,6 +67,11 @@ def run_streamed(input, turn_options: TurnOptions.new, &block) 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 diff --git a/lib/codex_sdk/version.rb b/lib/codex_sdk/version.rb index 903b539..c894432 100644 --- a/lib/codex_sdk/version.rb +++ b/lib/codex_sdk/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodexSDK - VERSION = "0.2.0" + VERSION = "0.2.1" end diff --git a/spec/codex_sdk/analysis_compatibility_spec.rb b/spec/codex_sdk/analysis_compatibility_spec.rb index 613b34d..c58b721 100644 --- a/spec/codex_sdk/analysis_compatibility_spec.rb +++ b/spec/codex_sdk/analysis_compatibility_spec.rb @@ -95,6 +95,7 @@ def client(env: {}) 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) From 91f7bd181001326911545def988e3d28cb4dd8dd Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Wed, 23 Sep 2026 17:15:38 +0300 Subject: [PATCH 3/5] test: cover interrupting an orphaned CLI descendant --- spec/codex_sdk/analysis_compatibility_spec.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spec/codex_sdk/analysis_compatibility_spec.rb b/spec/codex_sdk/analysis_compatibility_spec.rb index c58b721..aabe43b 100644 --- a/spec/codex_sdk/analysis_compatibility_spec.rb +++ b/spec/codex_sdk/analysis_compatibility_spec.rb @@ -106,6 +106,32 @@ def client(env: {}) 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 From 03c4f40d378833eea62fad057ba3a459ecb3650e Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Wed, 23 Sep 2026 17:23:02 +0300 Subject: [PATCH 4/5] fix: clear reaped CLI process identifiers --- CHANGELOG.md | 4 ++++ lib/codex_sdk/exec.rb | 2 ++ lib/codex_sdk/version.rb | 2 +- spec/codex_sdk/analysis_compatibility_spec.rb | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8b0c5..940e078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. diff --git a/lib/codex_sdk/exec.rb b/lib/codex_sdk/exec.rb index 8b8bf15..d5dfa6f 100644 --- a/lib/codex_sdk/exec.rb +++ b/lib/codex_sdk/exec.rb @@ -92,6 +92,8 @@ def interrupt begin Process.kill("TERM", -@pid) rescue Errno::ESRCH + @wait_thread.value + @pid = nil return end diff --git a/lib/codex_sdk/version.rb b/lib/codex_sdk/version.rb index c894432..105d95e 100644 --- a/lib/codex_sdk/version.rb +++ b/lib/codex_sdk/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodexSDK - VERSION = "0.2.1" + VERSION = "0.2.2" end diff --git a/spec/codex_sdk/analysis_compatibility_spec.rb b/spec/codex_sdk/analysis_compatibility_spec.rb index aabe43b..39fc2af 100644 --- a/spec/codex_sdk/analysis_compatibility_spec.rb +++ b/spec/codex_sdk/analysis_compatibility_spec.rb @@ -32,6 +32,7 @@ def client(env: {}) 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", From 110dc453c9866b4c67d388dee1960e6e91babed7 Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Wed, 23 Sep 2026 17:34:30 +0300 Subject: [PATCH 5/5] fix: preserve errors while process groups exit --- CHANGELOG.md | 4 ++++ lib/codex_sdk/exec.rb | 14 ++++++++++++-- lib/codex_sdk/version.rb | 2 +- spec/codex_sdk/exec_spec.rb | 20 ++++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 940e078..3a9d16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/lib/codex_sdk/exec.rb b/lib/codex_sdk/exec.rb index d5dfa6f..102eb31 100644 --- a/lib/codex_sdk/exec.rb +++ b/lib/codex_sdk/exec.rb @@ -90,7 +90,7 @@ def interrupt return unless @pid begin - Process.kill("TERM", -@pid) + signal_group("TERM") rescue Errno::ESRCH @wait_thread.value @pid = nil @@ -101,7 +101,7 @@ def interrupt wait_for_exit(SHUTDOWN_TIMEOUT) begin # Also stop descendants that outlive the immediate CLI child. - Process.kill("KILL", -@pid) + signal_group("KILL") rescue Errno::ESRCH # already gone end @@ -112,6 +112,16 @@ def interrupt 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 diff --git a/lib/codex_sdk/version.rb b/lib/codex_sdk/version.rb index 105d95e..6f3ff18 100644 --- a/lib/codex_sdk/version.rb +++ b/lib/codex_sdk/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodexSDK - VERSION = "0.2.2" + VERSION = "0.2.3" end diff --git a/spec/codex_sdk/exec_spec.rb b/spec/codex_sdk/exec_spec.rb index da3f0df..80c4b43 100644 --- a/spec/codex_sdk/exec_spec.rb +++ b/spec/codex_sdk/exec_spec.rb @@ -158,6 +158,26 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) end describe "#interrupt" do + it "preserves a consumer error when a disappearing process group reports EPERM" do + mocks = mock_popen3(stdout_lines: ['{"type":"turn.failed","error":{"message":"quota"}}']) + allow(mocks[:wait_thread]).to receive(:join).with(0.1) + allow(Process).to receive(:kill).with("TERM", -12_345).and_raise(Errno::EPERM) + allow(Process).to receive(:kill).with(0, -12_345).and_raise(Errno::ESRCH) + exec = described_class.new(options) + + expect { exec.run("test") { raise CodexSDK::Error, "quota" } }.to raise_error(CodexSDK::Error, "quota") + expect(exec.pid).to be_nil + end + + it "does not ignore permission errors for a process group that still exists" do + mocks = mock_popen3(stdout_lines: []) + allow(mocks[:wait_thread]).to receive(:join).with(0.1) + allow(Process).to receive(:kill).with("TERM", -12_345).and_raise(Errno::EPERM) + allow(Process).to receive(:kill).with(0, -12_345).and_return(1) + + expect { described_class.new(options).run("test") { nil } }.to raise_error(Errno::EPERM) + end + it "sends SIGTERM to the subprocess" do status = instance_double(Process::Status, success?: true, exitstatus: 0, termsig: nil) wait_thread = double("wait_thread", value: status, alive?: true, pid: 12_345)