fix: route remote control through provider startup args
lint / lint (push) Successful in 2m20s
test / unit (pull_request) Successful in 47s
test / integration (pull_request) Successful in 28s

This commit is contained in:
2026-06-25 00:30:50 -04:00
parent ecaae708f7
commit a6ae6841bb
14 changed files with 56 additions and 28 deletions
+1 -2
View File
@@ -61,7 +61,6 @@ class AgentProviderRuntime:
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_args: tuple[str, ...] resume_args: tuple[str, ...]
remote_control_args: tuple[str, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -391,7 +390,7 @@ def prompt_args(
if prompt_mode == "append_file": if prompt_mode == "append_file":
return ["--append-system-prompt-file", prompt_path] return ["--append-system-prompt-file", prompt_path]
if prompt_mode == "read_prompt_file": if prompt_mode == "read_prompt_file":
if argv and "resume" in argv: if argv and ("resume" in argv or "remote-control" in argv):
return [] return []
return [f"Read and follow the instructions in {prompt_path}."] return [f"Read and follow the instructions in {prompt_path}."]
if prompt_mode == "print_read_prompt_file": if prompt_mode == "print_read_prompt_file":
+1 -2
View File
@@ -109,9 +109,8 @@ class BottlePlan(ABC):
def workspace_plan(self) -> WorkspacePlan: def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home) return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self, *, remote_control: bool) -> None: def print(self) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
del remote_control
spec = self.spec spec = self.spec
manifest = self.manifest manifest = self.manifest
agent = manifest.agent agent = manifest.agent
-2
View File
@@ -28,7 +28,6 @@ from .start import _launch_bottle
def cmd_resume(argv: list[str]) -> int: def cmd_resume(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument( parser.add_argument(
"identity", "identity",
help="bottle identity from a prior `start` (see its session-end output)", help="bottle identity from a prior `start` (see its session-end output)",
@@ -56,6 +55,5 @@ def cmd_resume(argv: list[str]) -> int:
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=args.dry_run, dry_run=args.dry_run,
remote_control=args.remote_control,
backend_name=backend_name, backend_name=backend_name,
) )
+4 -10
View File
@@ -42,7 +42,6 @@ def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle") parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument( parser.add_argument(
"--backend", "--backend",
choices=known_backend_names(), choices=known_backend_names(),
@@ -89,7 +88,6 @@ def cmd_start(argv: list[str]) -> int:
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=dry_run, dry_run=dry_run,
remote_control=args.remote_control,
backend_name=backend_name, backend_name=backend_name,
) )
@@ -134,7 +132,7 @@ def prepare_with_preflight(
def attach_agent( def attach_agent(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False, bottle: Bottle, *, resume: bool = False,
agent_provider_template: str = "claude", agent_provider_template: str = "claude",
startup_args: tuple[str, ...] = (), startup_args: tuple[str, ...] = (),
) -> int: ) -> int:
@@ -153,8 +151,6 @@ def attach_agent(
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
) )
agent_args = list(runtime.bypass_args) agent_args = list(runtime.bypass_args)
if remote_control:
agent_args.extend(runtime.remote_control_args)
agent_args.extend(startup_args) agent_args.extend(startup_args)
if resume: if resume:
agent_args.extend(runtime.resume_args) agent_args.extend(runtime.resume_args)
@@ -218,9 +214,9 @@ def _text_prompt_yes() -> bool:
return reply in ("y", "Y", "yes", "YES") return reply in ("y", "Y", "yes", "YES")
def _text_render_preflight(*, remote_control: bool): def _text_render_preflight():
def _render(plan: DockerBottlePlan) -> None: def _render(plan: DockerBottlePlan) -> None:
plan.print(remote_control=remote_control) plan.print()
return _render return _render
@@ -228,7 +224,6 @@ def _launch_bottle(
spec: BottleSpec, spec: BottleSpec,
*, *,
dry_run: bool, dry_run: bool,
remote_control: bool,
backend_name: str | None = None, backend_name: str | None = None,
) -> int: ) -> int:
"""Shared launch core for `start` and `resume`. Builds the plan, """Shared launch core for `start` and `resume`. Builds the plan,
@@ -240,7 +235,7 @@ def _launch_bottle(
plan, identity = prepare_with_preflight( plan, identity = prepare_with_preflight(
spec, spec,
stage_dir=stage_dir, stage_dir=stage_dir,
render_preflight=_text_render_preflight(remote_control=remote_control), render_preflight=_text_render_preflight(),
prompt_yes=_text_prompt_yes, prompt_yes=_text_prompt_yes,
dry_run=dry_run, dry_run=dry_run,
backend_name=backend_name, backend_name=backend_name,
@@ -253,7 +248,6 @@ def _launch_bottle(
agent_provider_template = getattr(plan, "agent_provider_template", "claude") agent_provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_agent( exit_code = attach_agent(
bottle, bottle,
remote_control=remote_control,
agent_provider_template=agent_provider_template, agent_provider_template=agent_provider_template,
startup_args=plan.agent_provision.startup_args, startup_args=plan.agent_provision.startup_args,
) )
@@ -91,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
remote_control_args=("--remote-control",),
) )
+9 -6
View File
@@ -1,12 +1,12 @@
# bot-bottle Codex provider image. # bot-bottle Codex provider image.
# #
# Mirrors the default Claude image shape: Node LTS, git/network tooling, # Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed globally. # non-root node user, and the provider CLI installed for that user.
FROM node:22-slim FROM node:22-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl \ && apt-get install -y --no-install-recommends git ca-certificates curl procps \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by codex itself # App-specific deps. Python isn't required by codex itself
@@ -17,12 +17,15 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN npm install -g --no-fund --no-audit @openai/codex@0.136.0 \
&& npm cache clean --force
USER node USER node
WORKDIR /home/node WORKDIR /home/node
RUN mkdir -p /home/node/.codex ENV PATH="/home/node/.local/bin:${PATH}"
# Remote-control support requires the standalone Codex install layout
# under ~/.codex/packages/standalone/current. The npm package can run
# the TUI, but remote-control commands expect this installer-owned path.
RUN mkdir -p /home/node/.codex \
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
CMD ["codex"] CMD ["codex"]
@@ -55,7 +55,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
remote_control_args=(),
) )
-1
View File
@@ -166,7 +166,6 @@ _RUNTIME = AgentProviderRuntime(
prompt_mode="append_system_prompt", prompt_mode="append_system_prompt",
bypass_args=(), bypass_args=(),
resume_args=(), resume_args=(),
remote_control_args=(),
) )
+21
View File
@@ -102,6 +102,27 @@ class TestAttachAgent(unittest.TestCase):
bottle.argv, bottle.argv,
) )
def test_remote_control_is_provider_startup_arg(self):
class Bottle:
argv: list[str] = []
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
self.argv = list(argv)
return 0
bottle = Bottle()
exit_code = start_mod.attach_agent(
bottle, # type: ignore[arg-type]
agent_provider_template="codex",
startup_args=("remote-control",),
)
self.assertEqual(0, exit_code)
self.assertEqual(
["--dangerously-bypass-approvals-and-sandbox", "remote-control"],
bottle.argv,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -29,6 +29,9 @@ from bot_bottle.supervise import SupervisePlan
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
_CODEX_DOCKERFILE = (
Path(__file__).resolve().parents[2] / "bot_bottle/contrib/codex/Dockerfile"
)
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock: def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
@@ -276,6 +279,12 @@ class TestCodexProvision(unittest.TestCase):
) )
class TestCodexDockerfile(unittest.TestCase):
def test_installs_procps_for_remote_control_pid_management(self):
dockerfile = _CODEX_DOCKERFILE.read_text()
self.assertIn("procps", dockerfile)
class TestCodexSuperviseMcp(unittest.TestCase): class TestCodexSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self): def test_noop_when_supervise_disabled(self):
bottle = _make_bottle() bottle = _make_bottle()
+10
View File
@@ -136,6 +136,16 @@ class TestClaudeArgv(unittest.TestCase):
argv, argv,
) )
def test_codex_remote_control_startup_arg_does_not_receive_initial_prompt(self):
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-bypass-approvals-and-sandbox", "remote-control"],
)
self.assertEqual(
["docker", "exec", "-it", "bot-bottle-dev-abc", "codex",
"--dangerously-bypass-approvals-and-sandbox", "remote-control"],
argv,
)
def test_codex_resume_does_not_append_initial_prompt(self): def test_codex_resume_does_not_append_initial_prompt(self):
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv( argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"], ["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
@@ -31,7 +31,6 @@ class _Provider(AgentProvider):
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
) )
def provision_plan(self, **kwargs): # type: ignore[override] def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError raise NotImplementedError
+1 -1
View File
@@ -130,7 +130,7 @@ def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]
orig = sys.stderr orig = sys.stderr
sys.stderr = buf sys.stderr = buf
try: try:
plan.print(remote_control=False) plan.print()
finally: finally:
sys.stderr = orig sys.stderr = orig
return buf.getvalue().splitlines() return buf.getvalue().splitlines()
@@ -44,7 +44,6 @@ class _Provider(AgentProvider):
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
) )
def provision_plan(self, **kwargs): # type: ignore[override] def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError raise NotImplementedError