Skip to content
Open
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
36 changes: 35 additions & 1 deletion researchclaw/pipeline/opencode_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
import logging
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -470,7 +472,7 @@ def _invoke_opencode(
# Use -m flag to specify model (more reliable than opencode.json)
resolved_model = self._resolve_opencode_model()
opencode_cmd = shutil.which("opencode") or "opencode"
cmd = [opencode_cmd, "run", "-m", resolved_model, "--format", "json", prompt]
cmd = self._build_opencode_command(opencode_cmd, resolved_model, prompt)

t0 = time.monotonic()
try:
Expand Down Expand Up @@ -499,6 +501,38 @@ def _invoke_opencode(
elapsed = time.monotonic() - t0
return False, f"Unexpected error: {exc}", elapsed

@staticmethod
def _build_opencode_command(
opencode_cmd: str, resolved_model: str, prompt: str
) -> list[str]:
"""Build the argv for ``opencode run``, wrapping with a pseudo-TTY on Linux.

The ``opencode`` CLI requires a TTY: when invoked via ``subprocess.run``
with piped stdout/stderr it can return exit 0 with empty output and no
generated files. On Linux we wrap the call with util-linux
``script -q -e -c "<cmd>" /dev/null`` to provide a pseudo-TTY:

* ``-q`` suppresses ``script``'s start/done messages,
* ``-c`` runs the requested command,
* ``-e`` returns the child's exit status (without it ``script`` can
return 0 even when the child command fails, which would mask an
``opencode`` failure as success — the very silent-success behaviour
this wrapper exists to avoid).

The wrapper is gated on Linux specifically because the
``script -q -e -c ... /dev/null`` form is util-linux syntax; BSD/macOS
``script`` implementations are not compatible with it. On non-Linux
platforms, and when ``script`` is unavailable, we fall back to invoking
``opencode`` directly — i.e. the prior behaviour, no regression.
"""
direct = [opencode_cmd, "run", "-m", resolved_model, "--format", "json", prompt]

script_path = shutil.which("script")
if sys.platform.startswith("linux") and script_path:
inner = " ".join(shlex.quote(part) for part in direct)
return [script_path, "-q", "-e", "-c", inner, "/dev/null"]
return direct

# -- file collection -------------------------------------------------------

@staticmethod
Expand Down
91 changes: 91 additions & 0 deletions tests/test_opencode_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,97 @@ def test_invoke_opencode_uses_resolved_path(self, tmp_path):
assert run_mock.call_args.args[0][0].endswith("opencode.cmd")


# ============================================================
# TestBuildOpencodeCommand (PTY wrapper)
# ============================================================


class TestBuildOpencodeCommand:
"""Tests for _build_opencode_command — Linux pseudo-TTY wrapping."""

def test_linux_with_script_wraps_in_pty(self):
"""On Linux with `script` available, wrap via `script -q -e -c <cmd> /dev/null`."""
with patch(
"researchclaw.pipeline.opencode_bridge.sys.platform", "linux"
), patch(
"researchclaw.pipeline.opencode_bridge.shutil.which",
return_value="/usr/bin/script",
):
cmd = OpenCodeBridge._build_opencode_command(
"/usr/bin/opencode", "anthropic/claude-sonnet-4-6", "do the thing"
)
assert cmd[0] == "/usr/bin/script"
assert cmd[1:4] == ["-q", "-e", "-c"]
assert cmd[-1] == "/dev/null"
# The wrapped inner string must carry the full opencode invocation
inner = cmd[4]
assert "opencode" in inner
assert "run" in inner
assert "--format json" in inner

def test_linux_wrapper_preserves_child_exit_status(self):
"""The `-e` flag is required: without it `script` can return 0 even when
the child command fails, which would mask an opencode failure as success."""
with patch(
"researchclaw.pipeline.opencode_bridge.sys.platform", "linux"
), patch(
"researchclaw.pipeline.opencode_bridge.shutil.which",
return_value="/usr/bin/script",
):
cmd = OpenCodeBridge._build_opencode_command("/usr/bin/opencode", "m", "p")
assert "-e" in cmd

def test_linux_without_script_falls_back_to_direct(self):
"""On Linux but `script` missing → direct invocation, no wrapper."""
with patch(
"researchclaw.pipeline.opencode_bridge.sys.platform", "linux"
), patch(
"researchclaw.pipeline.opencode_bridge.shutil.which",
return_value=None,
):
cmd = OpenCodeBridge._build_opencode_command(
"/usr/bin/opencode", "anthropic/claude-sonnet-4-6", "prompt"
)
assert cmd == [
"/usr/bin/opencode", "run", "-m",
"anthropic/claude-sonnet-4-6", "--format", "json", "prompt",
]

def test_non_linux_does_not_wrap_even_if_script_present(self):
"""On macOS/BSD, `script` uses a different arg order; never wrap (no regression)."""
with patch(
"researchclaw.pipeline.opencode_bridge.sys.platform", "darwin"
), patch(
"researchclaw.pipeline.opencode_bridge.shutil.which",
return_value="/usr/bin/script",
):
cmd = OpenCodeBridge._build_opencode_command(
"/usr/bin/opencode", "anthropic/claude-sonnet-4-6", "prompt"
)
assert "script" not in cmd[0]
assert cmd[0] == "/usr/bin/opencode"

def test_prompt_with_shell_metacharacters_is_quoted(self):
"""The wrapped inner string must shell-quote the prompt so a shell
re-parses it back into the exact original argv (no injection)."""
import shlex

payload = "echo 'hi'; rm -rf /"
with patch(
"researchclaw.pipeline.opencode_bridge.sys.platform", "linux"
), patch(
"researchclaw.pipeline.opencode_bridge.shutil.which",
return_value="/usr/bin/script",
):
cmd = OpenCodeBridge._build_opencode_command("/usr/bin/opencode", "m", payload)
inner = cmd[4]
# A shell parsing `inner` must recover the original argv verbatim,
# with the payload as a single token rather than separate commands.
assert shlex.split(inner) == [
"/usr/bin/opencode", "run", "-m", "m", "--format", "json", payload,
]


# ============================================================
# TestEnsureMainEntryPoint (BUG-R52-01)
# ============================================================
Expand Down