From 7868367390e5056a464b99102a95aa45e02febe2 Mon Sep 17 00:00:00 2001 From: workspace Date: Tue, 21 Jul 2026 13:53:13 +0100 Subject: [PATCH 1/3] fix: propagate settings to subprocess agents --- package.json | 2 +- pyproject.toml | 2 +- .../acp/backends/script_backend.py | 10 +++- src/onemancompany/core/config.py | 10 ++++ src/onemancompany/core/subprocess_executor.py | 15 ++++- tests/unit/acp/test_script_backend.py | 4 +- tests/unit/core/test_subprocess_executor.py | 55 +++++++++++++++++++ 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9c3f54fb..956a5cd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1mancompany/onemancompany", - "version": "0.7.110", + "version": "0.7.111", "description": "The AI Operating System for One-Person Companies", "bin": { "onemancompany": "bin/cli.js" diff --git a/pyproject.toml b/pyproject.toml index a442a03f..eae5846a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onemancompany" -version = "0.7.110" +version = "0.7.111" description = "A one-man company simulation with pixel art visualization and LangChain AI agents" requires-python = ">=3.12" dependencies = [ diff --git a/src/onemancompany/acp/backends/script_backend.py b/src/onemancompany/acp/backends/script_backend.py index b57ec85c..0d41b726 100644 --- a/src/onemancompany/acp/backends/script_backend.py +++ b/src/onemancompany/acp/backends/script_backend.py @@ -20,7 +20,7 @@ from loguru import logger -from onemancompany.core.config import EMPLOYEES_DIR, ENV_OMC_PYTHON_EXECUTABLE +from onemancompany.core.config import EMPLOYEES_DIR, ENV_OMC_PYTHON_EXECUTABLE, get_settings_environment _LAUNCH_SH = "launch.sh" _TIMEOUT_SECONDS = 3600 @@ -107,6 +107,7 @@ async def execute( async def _run_subprocess(self, task_description: str, prompt_path: str) -> dict: env = { + **get_settings_environment(), **os.environ, "OMC_EMPLOYEE_ID": self._employee_id, "OMC_TASK_DESCRIPTION_FILE": prompt_path, @@ -153,7 +154,12 @@ async def _run_subprocess(self, task_description: str, prompt_path: str) -> dict return _error_result(f"[script timeout] {_TIMEOUT_SECONDS}s exceeded") if proc.returncode != 0: - err_msg = stderr.decode(errors="replace")[:500] if stderr else "Unknown error" + error_parts = [ + stream.decode(errors="replace").strip() + for stream in (stderr, stdout) + if stream + ] + err_msg = "\n".join(error_parts)[:500] or "Unknown error" error = f"Error (exit {proc.returncode}): {err_msg}" logger.warning( "ScriptAcpBackend: non-zero exit {} for employee {}: {}", diff --git a/src/onemancompany/core/config.py b/src/onemancompany/core/config.py index 8911303c..0d2e5f9f 100644 --- a/src/onemancompany/core/config.py +++ b/src/onemancompany/core/config.py @@ -607,6 +607,16 @@ class Settings(BaseSettings): settings = Settings() +def get_settings_environment() -> dict[str, str]: + """Return non-empty application settings in child-process env format.""" + values = settings.model_dump() + return { + key.upper(): str(value) + for key, value in values.items() + if value not in (None, "") + } + + def update_env_var(key: str, value: str) -> None: """Update or add a variable in the .env file, then reload settings. diff --git a/src/onemancompany/core/subprocess_executor.py b/src/onemancompany/core/subprocess_executor.py index 19a9ec90..c4b6aa99 100644 --- a/src/onemancompany/core/subprocess_executor.py +++ b/src/onemancompany/core/subprocess_executor.py @@ -19,7 +19,12 @@ from loguru import logger -from onemancompany.core.config import EMPLOYEES_DIR, ENV_OMC_PYTHON_EXECUTABLE, LAUNCH_SH_FILENAME +from onemancompany.core.config import ( + EMPLOYEES_DIR, + ENV_OMC_PYTHON_EXECUTABLE, + LAUNCH_SH_FILENAME, + get_settings_environment, +) from onemancompany.core.vessel import Launcher, LaunchResult, TaskContext _KILL_POLL_INTERVAL = 5 @@ -150,6 +155,7 @@ async def _run_subprocess( on_log: Callable[[str, str], None] | None, ) -> LaunchResult: env = { + **get_settings_environment(), **os.environ, "OMC_EMPLOYEE_ID": context.employee_id, "OMC_TASK_ID": context.task_id, @@ -192,7 +198,12 @@ async def _run_subprocess( on_log("stderr", stderr.decode(errors="replace")[:2000]) if self._process.returncode != 0: - err_msg = stderr.decode(errors="replace")[:500] if stderr else "Unknown error" + error_parts = [ + stream.decode(errors="replace").strip() + for stream in (stderr, stdout) + if stream + ] + err_msg = "\n".join(error_parts)[:500] or "Unknown error" error = f"Error (exit {self._process.returncode}): {err_msg}" if on_log: on_log("error", error) diff --git a/tests/unit/acp/test_script_backend.py b/tests/unit/acp/test_script_backend.py index 8e423abb..99d2e1a2 100644 --- a/tests/unit/acp/test_script_backend.py +++ b/tests/unit/acp/test_script_backend.py @@ -99,7 +99,7 @@ async def test_script_backend_handles_plain_stdout(self, tmp_path): async def test_script_backend_nonzero_exit_returns_error(self, tmp_path): """execute() returns error when subprocess exits non-zero.""" mock_proc = MagicMock() - mock_proc.communicate = AsyncMock(return_value=(b"", b"script failed")) + mock_proc.communicate = AsyncMock(return_value=(b"script failed in stdout", b"")) mock_proc.returncode = 1 mock_create = AsyncMock(return_value=mock_proc) @@ -123,7 +123,7 @@ async def test_script_backend_nonzero_exit_returns_error(self, tmp_path): ) assert result["error"] is not None - assert "script failed" in result["error"] or "exit" in result["error"] + assert "script failed in stdout" in result["error"] or "exit" in result["error"] def test_set_model_is_noop(self): """set_model() is a no-op for script backend.""" diff --git a/tests/unit/core/test_subprocess_executor.py b/tests/unit/core/test_subprocess_executor.py index e7454514..86fa0780 100644 --- a/tests/unit/core/test_subprocess_executor.py +++ b/tests/unit/core/test_subprocess_executor.py @@ -4,6 +4,7 @@ import asyncio import os import sys +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -189,6 +190,60 @@ async def capture_exec(*args, **kwargs): # Temp file should be cleaned up after execution assert not os.path.exists(captured_env["OMC_TASK_DESCRIPTION_FILE"]) + @pytest.mark.asyncio + async def test_execute_passes_configured_api_keys_to_subprocess(self, monkeypatch): + """Company settings loaded from .env are available to run.py children.""" + from onemancompany.core import config as config_module + from onemancompany.core.subprocess_executor import SubprocessExecutor + + monkeypatch.setattr( + config_module, + "settings", + SimpleNamespace( + model_dump=lambda: { + "openrouter_api_key": "sk-test-from-settings", + "openrouter_base_url": "https://openrouter.example/v1", + "google_api_key": "", + }, + ), + ) + exe = SubprocessExecutor(employee_id="00010", script_path="/tmp/test.sh") + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b'{"output":"ok"}', b"") + mock_proc.returncode = 0 + mock_proc.pid = 12345 + captured_env = {} + + async def capture_exec(*args, **kwargs): + captured_env.update(kwargs["env"]) + return mock_proc + + ctx = TaskContext(project_id="p1", work_dir="/tmp", employee_id="00010", task_id="t1") + with patch("onemancompany.core.subprocess_executor.asyncio.create_subprocess_exec", side_effect=capture_exec): + await exe.execute("hello", ctx) + + assert captured_env["OPENROUTER_API_KEY"] == "sk-test-from-settings" + assert captured_env["OPENROUTER_BASE_URL"] == "https://openrouter.example/v1" + assert "GOOGLE_API_KEY" not in captured_env + + @pytest.mark.asyncio + async def test_nonzero_exit_includes_stdout_in_error(self): + """run.py failures written to stdout remain visible to the caller.""" + from onemancompany.core.subprocess_executor import SubprocessExecutor + + exe = SubprocessExecutor(employee_id="00010", script_path="/tmp/test.sh") + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"Set OPENROUTER_API_KEY or api_key in profile.yaml", b"") + mock_proc.returncode = 1 + mock_proc.pid = 12345 + ctx = TaskContext(project_id="p1", work_dir="/tmp", employee_id="00010", task_id="t1") + + with patch("onemancompany.core.subprocess_executor.asyncio.create_subprocess_exec", return_value=mock_proc): + result = await exe.execute("hello", ctx) + + assert result.error is not None + assert "Set OPENROUTER_API_KEY" in result.error + @pytest.mark.asyncio async def test_prompt_file_cleaned_up_on_error(self): """Temp prompt file is cleaned up even when execution fails.""" From f34b99266537a7149b467200bb885270ba46dd08 Mon Sep 17 00:00:00 2001 From: workspace Date: Tue, 21 Jul 2026 14:06:07 +0100 Subject: [PATCH 2/3] fix: preserve subprocess stdout diagnostics --- package.json | 2 +- pyproject.toml | 2 +- .../acp/backends/script_backend.py | 14 +++++++------- src/onemancompany/core/config.py | 15 +++++++++++++++ src/onemancompany/core/subprocess_executor.py | 8 ++------ tests/unit/acp/test_script_backend.py | 4 ++-- tests/unit/core/test_subprocess_executor.py | 18 ++++++++++++++++++ 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 956a5cd0..af4a2d47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1mancompany/onemancompany", - "version": "0.7.111", + "version": "0.7.112", "description": "The AI Operating System for One-Person Companies", "bin": { "onemancompany": "bin/cli.js" diff --git a/pyproject.toml b/pyproject.toml index eae5846a..5a4efa70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onemancompany" -version = "0.7.111" +version = "0.7.112" description = "A one-man company simulation with pixel art visualization and LangChain AI agents" requires-python = ">=3.12" dependencies = [ diff --git a/src/onemancompany/acp/backends/script_backend.py b/src/onemancompany/acp/backends/script_backend.py index 0d41b726..6217bed0 100644 --- a/src/onemancompany/acp/backends/script_backend.py +++ b/src/onemancompany/acp/backends/script_backend.py @@ -20,7 +20,12 @@ from loguru import logger -from onemancompany.core.config import EMPLOYEES_DIR, ENV_OMC_PYTHON_EXECUTABLE, get_settings_environment +from onemancompany.core.config import ( + EMPLOYEES_DIR, + ENV_OMC_PYTHON_EXECUTABLE, + format_process_error, + get_settings_environment, +) _LAUNCH_SH = "launch.sh" _TIMEOUT_SECONDS = 3600 @@ -154,12 +159,7 @@ async def _run_subprocess(self, task_description: str, prompt_path: str) -> dict return _error_result(f"[script timeout] {_TIMEOUT_SECONDS}s exceeded") if proc.returncode != 0: - error_parts = [ - stream.decode(errors="replace").strip() - for stream in (stderr, stdout) - if stream - ] - err_msg = "\n".join(error_parts)[:500] or "Unknown error" + err_msg = format_process_error(stdout, stderr) error = f"Error (exit {proc.returncode}): {err_msg}" logger.warning( "ScriptAcpBackend: non-zero exit {} for employee {}: {}", diff --git a/src/onemancompany/core/config.py b/src/onemancompany/core/config.py index 0d2e5f9f..1a6d5a1f 100644 --- a/src/onemancompany/core/config.py +++ b/src/onemancompany/core/config.py @@ -617,6 +617,21 @@ def get_settings_environment() -> dict[str, str]: } +def format_process_error(stdout: bytes, stderr: bytes, *, max_length: int = 500) -> str: + """Keep both subprocess streams visible within the UI error-size limit.""" + streams = [ + stream.decode(errors="replace").strip() + for stream in (stderr, stdout) + if stream + ] + if not streams: + return "Unknown error" + if len(streams) == 1: + return streams[0][:max_length] + half_length = max_length // 2 + return f"{streams[0][:half_length]}\n{streams[1][:half_length]}" + + def update_env_var(key: str, value: str) -> None: """Update or add a variable in the .env file, then reload settings. diff --git a/src/onemancompany/core/subprocess_executor.py b/src/onemancompany/core/subprocess_executor.py index c4b6aa99..eca5a568 100644 --- a/src/onemancompany/core/subprocess_executor.py +++ b/src/onemancompany/core/subprocess_executor.py @@ -23,6 +23,7 @@ EMPLOYEES_DIR, ENV_OMC_PYTHON_EXECUTABLE, LAUNCH_SH_FILENAME, + format_process_error, get_settings_environment, ) from onemancompany.core.vessel import Launcher, LaunchResult, TaskContext @@ -198,12 +199,7 @@ async def _run_subprocess( on_log("stderr", stderr.decode(errors="replace")[:2000]) if self._process.returncode != 0: - error_parts = [ - stream.decode(errors="replace").strip() - for stream in (stderr, stdout) - if stream - ] - err_msg = "\n".join(error_parts)[:500] or "Unknown error" + err_msg = format_process_error(stdout, stderr) error = f"Error (exit {self._process.returncode}): {err_msg}" if on_log: on_log("error", error) diff --git a/tests/unit/acp/test_script_backend.py b/tests/unit/acp/test_script_backend.py index 99d2e1a2..8f2c94d9 100644 --- a/tests/unit/acp/test_script_backend.py +++ b/tests/unit/acp/test_script_backend.py @@ -99,7 +99,7 @@ async def test_script_backend_handles_plain_stdout(self, tmp_path): async def test_script_backend_nonzero_exit_returns_error(self, tmp_path): """execute() returns error when subprocess exits non-zero.""" mock_proc = MagicMock() - mock_proc.communicate = AsyncMock(return_value=(b"script failed in stdout", b"")) + mock_proc.communicate = AsyncMock(return_value=(b"script failed in stdout", b"E" * 1000)) mock_proc.returncode = 1 mock_create = AsyncMock(return_value=mock_proc) @@ -123,7 +123,7 @@ async def test_script_backend_nonzero_exit_returns_error(self, tmp_path): ) assert result["error"] is not None - assert "script failed in stdout" in result["error"] or "exit" in result["error"] + assert "script failed in stdout" in result["error"] def test_set_model_is_noop(self): """set_model() is a no-op for script backend.""" diff --git a/tests/unit/core/test_subprocess_executor.py b/tests/unit/core/test_subprocess_executor.py index 86fa0780..5bec617e 100644 --- a/tests/unit/core/test_subprocess_executor.py +++ b/tests/unit/core/test_subprocess_executor.py @@ -244,6 +244,24 @@ async def test_nonzero_exit_includes_stdout_in_error(self): assert result.error is not None assert "Set OPENROUTER_API_KEY" in result.error + @pytest.mark.asyncio + async def test_nonzero_exit_preserves_stdout_when_stderr_is_long(self): + """A verbose stderr traceback must not hide the useful stdout diagnosis.""" + from onemancompany.core.subprocess_executor import SubprocessExecutor + + exe = SubprocessExecutor(employee_id="00010", script_path="/tmp/test.sh") + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"API_KEY_FAILURE", b"E" * 1000) + mock_proc.returncode = 1 + mock_proc.pid = 12345 + ctx = TaskContext(project_id="p1", work_dir="/tmp", employee_id="00010", task_id="t1") + + with patch("onemancompany.core.subprocess_executor.asyncio.create_subprocess_exec", return_value=mock_proc): + result = await exe.execute("hello", ctx) + + assert result.error is not None + assert "API_KEY_FAILURE" in result.error + @pytest.mark.asyncio async def test_prompt_file_cleaned_up_on_error(self): """Temp prompt file is cleaned up even when execution fails.""" From ad0e6fd2db3b93664ca0373cfe1dcba964648cbc Mon Sep 17 00:00:00 2001 From: workspace Date: Tue, 21 Jul 2026 17:33:44 +0100 Subject: [PATCH 3/3] fix: enforce subprocess error length limit --- package.json | 2 +- pyproject.toml | 2 +- src/onemancompany/core/config.py | 3 ++- tests/unit/core/test_subprocess_executor.py | 3 +++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index af4a2d47..7bb263e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1mancompany/onemancompany", - "version": "0.7.112", + "version": "0.7.113", "description": "The AI Operating System for One-Person Companies", "bin": { "onemancompany": "bin/cli.js" diff --git a/pyproject.toml b/pyproject.toml index 5a4efa70..13a1c905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onemancompany" -version = "0.7.112" +version = "0.7.113" description = "A one-man company simulation with pixel art visualization and LangChain AI agents" requires-python = ">=3.12" dependencies = [ diff --git a/src/onemancompany/core/config.py b/src/onemancompany/core/config.py index 1a6d5a1f..d01be51b 100644 --- a/src/onemancompany/core/config.py +++ b/src/onemancompany/core/config.py @@ -629,7 +629,8 @@ def format_process_error(stdout: bytes, stderr: bytes, *, max_length: int = 500) if len(streams) == 1: return streams[0][:max_length] half_length = max_length // 2 - return f"{streams[0][:half_length]}\n{streams[1][:half_length]}" + second_length = max_length - half_length - 1 + return f"{streams[0][:half_length]}\n{streams[1][:second_length]}" def update_env_var(key: str, value: str) -> None: diff --git a/tests/unit/core/test_subprocess_executor.py b/tests/unit/core/test_subprocess_executor.py index 5bec617e..45843e91 100644 --- a/tests/unit/core/test_subprocess_executor.py +++ b/tests/unit/core/test_subprocess_executor.py @@ -262,6 +262,9 @@ async def test_nonzero_exit_preserves_stdout_when_stderr_is_long(self): assert result.error is not None assert "API_KEY_FAILURE" in result.error + diagnostic = result.error.split(": ", 1)[1] + assert len(diagnostic) <= 500 + @pytest.mark.asyncio async def test_prompt_file_cleaned_up_on_error(self): """Temp prompt file is cleaned up even when execution fails."""