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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@1mancompany/onemancompany",
"version": "0.7.110",
"version": "0.7.113",
"description": "The AI Operating System for One-Person Companies",
"bin": {
"onemancompany": "bin/cli.js"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "onemancompany"
version = "0.7.110"
version = "0.7.113"
description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
requires-python = ">=3.12"
dependencies = [
Expand Down
10 changes: 8 additions & 2 deletions src/onemancompany/acp/backends/script_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@

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,
format_process_error,
get_settings_environment,
)

_LAUNCH_SH = "launch.sh"
_TIMEOUT_SECONDS = 3600
Expand Down Expand Up @@ -107,6 +112,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,
Expand Down Expand Up @@ -153,7 +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:
err_msg = stderr.decode(errors="replace")[:500] if stderr else "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 {}: {}",
Expand Down
26 changes: 26 additions & 0 deletions src/onemancompany/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,32 @@ 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 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
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:
"""Update or add a variable in the .env file, then reload settings.

Expand Down
11 changes: 9 additions & 2 deletions src/onemancompany/core/subprocess_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@

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,
format_process_error,
get_settings_environment,
)
from onemancompany.core.vessel import Launcher, LaunchResult, TaskContext

_KILL_POLL_INTERVAL = 5
Expand Down Expand Up @@ -150,6 +156,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,
Expand Down Expand Up @@ -192,7 +199,7 @@ 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"
err_msg = format_process_error(stdout, stderr)
error = f"Error (exit {self._process.returncode}): {err_msg}"
if on_log:
on_log("error", error)
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/acp/test_script_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"E" * 1000))
mock_proc.returncode = 1

mock_create = AsyncMock(return_value=mock_proc)
Expand All @@ -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"]

def test_set_model_is_noop(self):
"""set_model() is a no-op for script backend."""
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/core/test_subprocess_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -189,6 +190,81 @@ 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_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

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."""
Expand Down
Loading