diff --git a/docker-compose.yaml b/docker-compose.yaml index d81d65b8..b82f6a10 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -529,6 +529,10 @@ services: ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true} ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true} ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true} + # Telegram notifications bridge: best-effort DMs to the CEO on escalation + # + completion. Config default is OFF; not yet armed here (needs stored + # bot-token + chat-id credentials regardless of this flag). + ROBOCO_TELEGRAM_ENABLED: ${ROBOCO_TELEGRAM_ENABLED:-false} ROBOCO_OBSIDIAN_VAULT_ENABLED: ${ROBOCO_OBSIDIAN_VAULT_ENABLED:-true} ROBOCO_VAULT_PATH: ${ROBOCO_VAULT_PATH:-/app/vault} ROBOCO_VAULT_INTAKE_ENABLED: ${ROBOCO_VAULT_INTAKE_ENABLED:-true} diff --git a/docker-compose.yml b/docker-compose.yml index d81d65b8..b82f6a10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -529,6 +529,10 @@ services: ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true} ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true} ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true} + # Telegram notifications bridge: best-effort DMs to the CEO on escalation + # + completion. Config default is OFF; not yet armed here (needs stored + # bot-token + chat-id credentials regardless of this flag). + ROBOCO_TELEGRAM_ENABLED: ${ROBOCO_TELEGRAM_ENABLED:-false} ROBOCO_OBSIDIAN_VAULT_ENABLED: ${ROBOCO_OBSIDIAN_VAULT_ENABLED:-true} ROBOCO_VAULT_PATH: ${ROBOCO_VAULT_PATH:-/app/vault} ROBOCO_VAULT_INTAKE_ENABLED: ${ROBOCO_VAULT_INTAKE_ENABLED:-true} diff --git a/docker/scripts/bash-guard-hook.sh b/docker/scripts/bash-guard-hook.sh index c47e6679..8a0f4c6a 100755 --- a/docker/scripts/bash-guard-hook.sh +++ b/docker/scripts/bash-guard-hook.sh @@ -393,7 +393,7 @@ fi # agent's command string, not subprocesses) and WorkspaceService's uv sync # (subprocess, not the agent Bash tool) are untouched. On grok a deny cancels # the whole run, so ROBOCO_GUARD_SKIP_PM=1 nudges (exit 0) instead. -if test -f Makefile && echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(run|pip[[:space:]]+(install|uninstall)|lock|add|remove)|pip3?[[:space:]]+(install|uninstall)|conda[[:space:]]+(install|create|run)|poetry[[:space:]]+(run|install|add))([[:space:]]|$)'; then +if test -f Makefile && echo "$low" | grep -qE '(^|[[:space:];&|])(uv[[:space:]]+(run|sync|pip[[:space:]]+(install|uninstall)|lock|add|remove)|pip3?[[:space:]]+(install|uninstall)|conda[[:space:]]+(install|create|run)|poetry[[:space:]]+(run|install|add))([[:space:]]|$)'; then if [ -n "${ROBOCO_GUARD_SKIP_PM:-}" ]; then echo "Nudge: raw package-manager commands are blocked — use \`make quality\` / \`make gate\` / \`make lint\` / \`make test\`. The Makefile sets UV_NO_SYNC=1 + a private cache; bare \`uv run\` bypasses that." >&2 exit 0 diff --git a/roboco/llm/providers/grok_cli_config.py b/roboco/llm/providers/grok_cli_config.py index 44f460b8..43bee839 100644 --- a/roboco/llm/providers/grok_cli_config.py +++ b/roboco/llm/providers/grok_cli_config.py @@ -133,6 +133,7 @@ _DESTRUCTIVE_DENY = ("Bash(rm -rf*)",) # ROBOCO_GUARD_SKIP_PM=1 instead of canceling the run. _RAW_PM_DENY = ( "Bash(uv run*)", + "Bash(uv sync*)", "Bash(uv pip install*)", "Bash(uv pip uninstall*)", "Bash(uv lock*)", diff --git a/roboco/services/release_executor.py b/roboco/services/release_executor.py index 77dc374f..90fc8701 100644 --- a/roboco/services/release_executor.py +++ b/roboco/services/release_executor.py @@ -261,7 +261,7 @@ class _ReleaseContext: """Writable-clone coordinates for the production release ops.""" slug: str - default_branch: str + prod_branch: str root: Path git_url: str # Per-call ``-c http.extraheader=Authorization: Basic …`` prefix so the PAT @@ -281,7 +281,7 @@ class _GitReleaseOps: def __init__(self, session: AsyncSession, ctx: _ReleaseContext) -> None: self._session = session self._slug = ctx.slug - self._default_branch = ctx.default_branch + self._default_branch = ctx.prod_branch self._root = ctx.root self._git_url = ctx.git_url self._git_prefix = ctx.git_prefix @@ -556,7 +556,7 @@ async def get_release_executor(session: AsyncSession) -> ReleaseExecutor: root = await _prepare_release_clone(slug, git_url, git_prefix, default_branch) ctx = _ReleaseContext( slug=slug, - default_branch=default_branch, + prod_branch=default_branch, root=root, git_url=git_url, git_prefix=git_prefix, diff --git a/tests/e2e_smoke/test_background_engines.py b/tests/e2e_smoke/test_background_engines.py index f172b9f0..f5891e9b 100644 --- a/tests/e2e_smoke/test_background_engines.py +++ b/tests/e2e_smoke/test_background_engines.py @@ -97,7 +97,7 @@ async def test_h24_wait_for_ci_polls_through_non_success( ctx = _ReleaseContext( slug="roboco", - default_branch="master", + prod_branch="master", root=Path("/tmp/release-e2e"), git_url="", git_prefix=[], diff --git a/tests/foundation/test_lifecycle_smoke_replay.py b/tests/foundation/test_lifecycle_smoke_replay.py index 36d8a5e2..44caf15c 100644 --- a/tests/foundation/test_lifecycle_smoke_replay.py +++ b/tests/foundation/test_lifecycle_smoke_replay.py @@ -350,8 +350,8 @@ def _assert_spec_introspection(record: dict[str, Any]) -> None: f"the spec. Fix commit: {record['fix_commit']}" ) all_assigned_verbs: set[str] = set() - for r in spec.Role: - all_assigned_verbs.update(spec.intents_for_role(r)) + for any_role in spec.Role: + all_assigned_verbs.update(spec.intents_for_role(any_role)) for verb_name in spec._INTENT_VERBS: assert verb_name in all_assigned_verbs, ( f"bug {bug_id} regressed: verb {verb_name!r} is declared in " diff --git a/tests/unit/llm/providers/test_grok_raw_pm_deny.py b/tests/unit/llm/providers/test_grok_raw_pm_deny.py index bc042923..13307e7a 100644 --- a/tests/unit/llm/providers/test_grok_raw_pm_deny.py +++ b/tests/unit/llm/providers/test_grok_raw_pm_deny.py @@ -16,6 +16,7 @@ from roboco.llm.providers.grok_cli_config import _RAW_PM_DENY, _deny_rules def test_raw_pm_deny_rules_present() -> None: rules = _deny_rules("developer") assert "Bash(uv run*)" in rules + assert "Bash(uv sync*)" in rules assert "Bash(uv pip install*)" in rules assert "Bash(pip install*)" in rules assert "Bash(conda install*)" in rules diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py index 55c97dab..6c715d8a 100644 --- a/tests/unit/scripts/test_bash_guard.py +++ b/tests/unit/scripts/test_bash_guard.py @@ -23,13 +23,14 @@ _DENIED = 2 _ALLOWED = 0 -def _run(cmd: str) -> int: +def _run(cmd: str, cwd: Path | None = None) -> int: payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}) result = subprocess.run( [str(GUARD)], input=payload, capture_output=True, text=True, + cwd=str(cwd) if cwd else None, check=False, ) return result.returncode @@ -432,14 +433,22 @@ def test_blocks_app_mutation_even_in_grok_mode() -> None: assert result.returncode == _DENIED -def test_allows_uv_sync_in_workspace() -> None: - """Legit dependency sync in the agent's own workspace clone must pass.""" +def test_denies_uv_sync_in_workspace_when_makefile_present() -> None: + """W1: bare ``uv sync`` uses the shared ~/.cache/uv instead of the Makefile's + private UV_CACHE_DIR — the cache-poisoning race the guard exists to + prevent. Makefile-gated like ``uv run``: use ``uv sync --extra dev`` via + ``make`` targets, not directly.""" assert ( _run("cd /data/workspaces/roboco/backend/be-dev-1 && uv sync --extra dev") - == _ALLOWED + == _DENIED ) +def test_allows_uv_sync_in_workspace_without_makefile(tmp_path: Path) -> None: + """Makefile-less projects skip the deny — bare ``uv sync`` still passes.""" + assert _run("uv sync --extra dev", cwd=tmp_path) == _ALLOWED + + def test_denies_pip_install_when_makefile_present() -> None: """W1: raw ``pip install`` is Makefile-gated. A workspace clone carries a ``Makefile`` (same repo), so a bare ``pip install`` is denied — agents @@ -454,10 +463,15 @@ def test_allows_reading_files_under_app() -> None: assert _run("ls -la /app/.venv/bin") == _ALLOWED -def test_allows_uv_sync_for_app_named_workspace_project() -> None: +def test_allows_uv_sync_for_app_named_workspace_project(tmp_path: Path) -> None: """A workspace path that merely contains 'app' (e.g. .../myapp/...) must not - trip the rule — the boundary requires /app to be its own path segment.""" - assert _run("cd /data/workspaces/myapp/backend/be-dev-1 && uv sync") == _ALLOWED + trip the /app-boundary rule — the boundary requires /app to be its own path + segment. Run outside a Makefile-having cwd so the W1 raw-uv-sync gate + (a separate rule, covered above) doesn't also fire here.""" + assert ( + _run("cd /data/workspaces/myapp/backend/be-dev-1 && uv sync", cwd=tmp_path) + == _ALLOWED + ) # --------------------------------------------------------------------------- diff --git a/tests/unit/services/test_release_executor.py b/tests/unit/services/test_release_executor.py index 04b2f98d..6e6edd71 100644 --- a/tests/unit/services/test_release_executor.py +++ b/tests/unit/services/test_release_executor.py @@ -311,7 +311,7 @@ async def test_wait_for_ci_scoped_to_release_commit_not_branch_latest( ctx = _ReleaseContext( slug="roboco-api", - default_branch="master", + prod_branch="master", root=tmp_path, git_url="x", git_prefix=[], @@ -362,7 +362,7 @@ async def test_wait_for_ci_polls_through_rerun( ctx = _ReleaseContext( slug="roboco-api", - default_branch="master", + prod_branch="master", root=tmp_path, git_url="x", git_prefix=[], @@ -409,7 +409,7 @@ async def test_wait_for_ci_exhausts_window_on_persistent_failure( ctx = _ReleaseContext( slug="roboco-api", - default_branch="master", + prod_branch="master", root=tmp_path, git_url="x", git_prefix=[], @@ -538,7 +538,7 @@ async def test_release_push_argv_uses_extraheader_not_url_token( ctx = _ReleaseContext( slug="roboco-api", - default_branch="master", + prod_branch="master", root=tmp_path, git_url=git_url, git_prefix=git_prefix, diff --git a/tests/unit/services/test_release_executor_commit_fail_closed.py b/tests/unit/services/test_release_executor_commit_fail_closed.py index e24fc29d..2754f177 100644 --- a/tests/unit/services/test_release_executor_commit_fail_closed.py +++ b/tests/unit/services/test_release_executor_commit_fail_closed.py @@ -16,7 +16,7 @@ from roboco.services.release_executor import _GitReleaseOps, _ReleaseContext def _ctx() -> _ReleaseContext: return _ReleaseContext( slug="roboco", - default_branch="master", + prod_branch="master", root=Path("/tmp/roboco-release-f012"), git_url="https://github.com/o/roboco", git_prefix=[], @@ -32,7 +32,7 @@ class _FakeGitOps(_GitReleaseOps): # Bypass the real __init__ (no session needed) — we only exercise # commit_and_push, which calls self._git. self._slug = ctx.slug - self._default_branch = ctx.default_branch + self._default_branch = ctx.prod_branch self._root = ctx.root self._git_url = ctx.git_url self._git_prefix = ctx.git_prefix