From aab7ee1941cf4c2532b43f571f565cc7e54b5cda Mon Sep 17 00:00:00 2001 From: mbajaj92 Date: Sun, 6 Sep 2026 19:57:37 -0700 Subject: [PATCH] feat(cron): bind scheduled jobs to a project's agents Adds an optional operating folder to a cron job, so its agent picker and fire-time execution can use project-scoped agents from /.kiro/agents/*.json instead of only global agents. Fire-time behavior when the folder existed at save time but is gone by the time the job runs: the run is skipped and recorded as a failure, since executing it against the wrong (global-fallback) agent would silently do the wrong thing. Closes #8952 --- .github/black-baseline.txt | 10 - docs/feature-map/README.md | 2 +- src/kiro_crew/cron.py | 223 ++- src/kiro_crew/dashboard/handlers/agents.py | 132 +- src/kiro_crew/dashboard/handlers/cron.py | 227 ++- src/kiro_crew/security/__init__.py | 10 +- src/kiro_crew/security/_exports.py | 2 + src/kiro_crew/security/paths.py | 41 + src/kiro_crew/slack/gateway.py | 375 ++++- test/test_agent_spec_hardened_reads.py | 6 +- test/test_agents_project_path_owner_gate.py | 97 ++ test/test_agents_roster_contract.py | 4 +- test/test_api_agents_order.py | 16 +- test/test_cron.py | 222 ++- test/test_cron_acp_retry.py | 21 +- test/test_cron_approval_mode.py | 88 +- test/test_cron_dedup.py | 33 +- test/test_cron_gateway_integration.py | 230 +++ test/test_cron_handler_json_contract.py | 44 +- test/test_cron_minimal_context_api.py | 26 +- ...test_cron_patch_project_path_validation.py | 122 ++ .../test_cron_project_bound_job_owner_gate.py | 130 ++ test/test_cron_project_bound_job_toctou.py | 224 +++ test/test_cron_project_path_owner_gate.py | 175 +++ test/test_cron_refusal_status.py | 8 +- test/test_cron_run_failure_alert.py | 1 + test/test_cron_slack_delivery.py | 15 +- test/test_cron_source_preset_api.py | 8 +- test/test_cron_string_field_validation.py | 7 + test/test_cron_thread_routing.py | 127 +- test/test_cron_wake_budget.py | 1 + test/test_dashboard_cron_approval.py | 119 ++ test/test_dashboard_cron_folder_id.py | 6 + test/test_dashboard_cron_hide_in_chat.py | 5 + test/test_dashboard_cron_run_guard.py | 10 +- test/test_dashboard_cron_update_agent.py | 10 + test/test_slack_cron_remove_audit.py | 1 + test/test_slack_gateway.py | 1362 +++++++++++------ website/src/api/client.ts | 14 +- website/src/components/JobForm.tsx | 188 ++- website/src/components/ProjectPicker.tsx | 67 +- website/src/hooks/useAgents.ts | 10 +- website/src/i18n/locales/bn.json | 7 + website/src/i18n/locales/de.json | 7 + website/src/i18n/locales/en-XA.json | 7 + website/src/i18n/locales/en.json | 7 + website/src/i18n/locales/es.json | 7 + website/src/i18n/locales/fr.json | 7 + website/src/i18n/locales/hi.json | 7 + website/src/i18n/locales/it.json | 7 + website/src/i18n/locales/ja.json | 7 + website/src/i18n/locales/ko.json | 9 +- website/src/i18n/locales/pt.json | 7 + website/src/i18n/locales/ru.json | 7 + website/src/i18n/locales/zh-CN.json | 7 + website/src/test/FileChangeChipsAnim.test.tsx | 9 +- .../JobForm.projectOnlyAgentReset.test.tsx | 130 ++ .../test/JobForm.projectRosterError.test.tsx | 105 ++ website/src/test/JobForm.saveError.test.tsx | 113 ++ .../src/test/JobForm.scriptCommand.test.tsx | 10 + website/src/types/index.ts | 1 + 61 files changed, 4121 insertions(+), 759 deletions(-) create mode 100644 test/test_agents_project_path_owner_gate.py create mode 100644 test/test_cron_patch_project_path_validation.py create mode 100644 test/test_cron_project_bound_job_owner_gate.py create mode 100644 test/test_cron_project_bound_job_toctou.py create mode 100644 test/test_cron_project_path_owner_gate.py create mode 100644 website/src/test/JobForm.projectOnlyAgentReset.test.tsx create mode 100644 website/src/test/JobForm.projectRosterError.test.tsx create mode 100644 website/src/test/JobForm.saveError.test.tsx diff --git a/.github/black-baseline.txt b/.github/black-baseline.txt index 40d9df845d9..75cf9458fd6 100644 --- a/.github/black-baseline.txt +++ b/.github/black-baseline.txt @@ -292,7 +292,6 @@ src/kiro_crew/pod/provision.py src/kiro_crew/publish_governance.py src/kiro_crew/publish_sync.py src/kiro_crew/resource_status.py -src/kiro_crew/security/__init__.py src/kiro_crew/sel.py src/kiro_crew/service/apparmor.py src/kiro_crew/service/common.py @@ -350,7 +349,6 @@ test/test_ai_agent_runner_coverage.py test/test_ai_backend_routes_coverage.py test/test_ai_pr_watchers_coverage.py test/test_api_agent_config_put_succeeds.py -test/test_api_agents_order.py test/test_api_file_diff.py test/test_api_hook_test_stop_payload.py test/test_api_input_validation.py @@ -492,25 +490,18 @@ test/test_context_ui_language.py test/test_continuable_followups.py test/test_crash_dump_store.py test/test_crash_guard.py -test/test_cron.py -test/test_cron_acp_retry.py -test/test_cron_approval_mode.py test/test_cron_arbiter_items.py test/test_cron_cancel.py test/test_cron_context_meter_seed.py test/test_cron_count_from_disk.py -test/test_cron_dedup.py test/test_cron_history.py test/test_cron_locking_regression.py test/test_cron_message_cap.py test/test_cron_preview_cmd.py test/test_cron_reaper.py -test/test_cron_refusal_status.py test/test_cron_script.py test/test_cron_sdk.py test/test_cron_skip_dates.py -test/test_cron_slack_delivery.py -test/test_cron_thread_routing.py test/test_cron_timezone_display.py test/test_cron_trigger.py test/test_cse_2026_08_05_fixes.py @@ -943,7 +934,6 @@ test/test_skill_versioning.py test/test_skills.py test/test_slack_agent_passthrough.py test/test_slack_dashboard_live_sync.py -test/test_slack_gateway.py test/test_slack_handler_coverage_branches.py test/test_slack_home_tab.py test/test_slack_inline_stop.py diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index 35ce818f952..bd133fdca5f 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -191,7 +191,7 @@ It also verifies accepting and rejecting conflict proposals in each lineage. | Feature | What it is | Reach it | Page | Handler | Endpoints | |---|---|---|---|---|---| -| Schedule | Cron jobs: recurring agent turns, scripts, commands | `/schedule` — rail **Schedule**; also created inline from the crew editor's "What wakes this crew" section (`/capabilities?tab=crews`) | `pages/SchedulePage.tsx`, `components/CrewWakeSection.tsx` | `handlers/cron.py` | `GET,POST /api/crons`, `DELETE /api/crons/{job_id}`, `GET /api/crons/history` | +| Schedule | Cron jobs: recurring agent turns, scripts, commands; a job can bind to a project folder so it runs with that project's own agents | `/schedule` — rail **Schedule**; also created inline from the crew editor's "What wakes this crew" section (`/capabilities?tab=crews`) | `pages/SchedulePage.tsx`, `components/JobForm.tsx`, `components/CrewWakeSection.tsx` | `handlers/cron.py`, `handlers/agents.py` | `GET,POST /api/crons`, `DELETE /api/crons/{job_id}`, `GET /api/crons/history`, `GET /api/agents?project_path` | | Cron secret grants | Owner-approved vault-secret env grants for script crons: agent requests via `cron_secret_request`, the owner approves/denies/revokes on the job's Secrets panel | `/schedule` → job → **Secrets** | `pages/SchedulePage.tsx` (`JobSecretsPanel`) | `handlers/cron.py` | `PUT /api/crons/{job_id}/secrets` | | Template update signal | A job created from a Schedule template records the template's id and prompt snapshot; the job detail panel shows a dismissible hint (naming the template) when that template's prompt has since changed | `/schedule` → job detail | `pages/SchedulePage.tsx` (`TemplateUpdatedNotice`), `utils/schedulePresets.tsx` (`templateUpdate`) | `handlers/cron.py` | `POST /api/crons` (accepts `source_preset`, `source_template_prompt`), `GET,POST /api/crons` | | Monitor loops | Same-session bounded monitors and legacy nudge loops watching an external thing; a member's loop is also surfaced read-only in the Crew Members side panel's Crew summary tab | Chat composer → monitor popover; Crew Members → side panel → Crew summary → Auto patrol; agent-armed | `components/SessionAutomationPopover.tsx`, `components/AutoNudgePopover.tsx`, `components/autoNudgeLoop.ts`, `pages/members/MembersPage.tsx` (read-only) | `handlers/autonudge.py` | `GET,POST /api/monitors`, `PATCH /api/monitors/{id}`, `GET /api/monitors/slot/{slot_key}`, `POST /api/monitors/{id}/stop`, `POST /api/monitors/{id}/clear`, `POST /api/monitors/{id}/restart`, `GET,POST /api/autonudge`, `PATCH,DELETE /api/autonudge/{loop_id}`, `POST /api/autonudge/{loop_id}/fire` | diff --git a/src/kiro_crew/cron.py b/src/kiro_crew/cron.py index b609a89c7d8..48b5588aa8d 100644 --- a/src/kiro_crew/cron.py +++ b/src/kiro_crew/cron.py @@ -26,6 +26,7 @@ import json import logging import math +import os import random import re import threading @@ -56,17 +57,13 @@ shutdown_event, stall_attribution, ) -from kiro_crew.config.loader import ( - KiroCrewConfig, - config_dir, - data_home, - published_config_timezone, -) +from kiro_crew.config.loader import KiroCrewConfig, config_dir, data_home, published_config_timezone from kiro_crew.constants import env_flag_enabled from kiro_crew.cron_history import CronHistoryStore, CronRunRecord from kiro_crew.executors import _CRON_QUEUE_WAIT_SECS, cron_gate_budget, subprocess_executor from kiro_crew.metrics.events import CRON_FIRES, emit_counter from kiro_crew.resource_status import admission_check +from kiro_crew.security import resolve_project_path from kiro_crew.validation import CHANNEL_MAX_LEN, MAX_CRON_MESSAGE, MAX_SHORT_STRING logger = logging.getLogger(__name__) @@ -94,6 +91,7 @@ ("command", 5000), ("script", 200), ("timezone", 50), + ("project_path", MAX_SHORT_STRING), # Secret-grant fields have no boundary FieldSpec: the pins are sha256 hex # digests computed server-side by the grant endpoint / cron_secret_request # tool (grant validity is enforced by pin equality at fire time, not by @@ -367,6 +365,12 @@ def job_agent_names_from_disk() -> list[tuple[str, str]]: _STORE_VERSION = 2 _MIN_INTERVAL_SECS = 60 _JOB_TIMEOUT_SECS = 1800 # 30 min per job +# Distinguishes "the caller passed no expect_project_path precondition" from +# "the caller expects the field to be the empty string (unbound)" -- both are +# meaningfully different states for `_update_job_locked`'s compare-and-swap, +# and `None`/`""` cannot tell them apart since "" is itself a valid expected +# value, not an absence of one. +_UNSET = object() # Margin the per-wake budget must leave above a command/script subprocess # timeout: the wake deadline cancels only the executor FUTURE (threads are # not interruptible), so a budget shorter than the subprocess bound leaves @@ -759,6 +763,16 @@ class CronJob: skip_dates: list[str] = field(default_factory=list) # ISO dates to skip ["2026-04-06"] timezone: str = "" # IANA timezone for skip evaluation persistent_session: bool = True # False → fresh ephemeral session per run + # Absolute path to a project directory whose .kiro/agents/*.json this job's + # agent_id may resolve against. "" = global agent only (default, unchanged + # behavior). Validated at add_job() time (must exist, must not be a + # sensitive path) mirroring chat_folders._validate_project_dir. A path that + # existed at save time but is gone by fire time is NOT re-validated here — + # the fire-time path (slack/gateway.py) does that check itself and SKIPS + # the run entirely when the folder is gone, surfacing it via a normal + # last_status="error" on the run record rather than falling back to a + # global agent. + project_path: str = "" minimal_context: bool = False # True → skip memory/lessons/skills/history hide_in_chat: bool = ( False # True → don't create a dashboard chat slot; result still goes to history + Slack/bell @@ -1949,6 +1963,7 @@ def _guard_num(field: str, default: Any) -> Any: consecutive_failures=_guard_num("consecutive_failures", 0), skip_dates=_str_list("skip_dates"), timezone=_guard_str("timezone"), + project_path=_guard_str("project_path"), persistent_session=j.get("persistent_session", True), minimal_context=j.get("minimal_context", False), hide_in_chat=j.get("hide_in_chat", False), @@ -2618,6 +2633,7 @@ def add_job( minimal_context: bool = False, timeout: int = 0, timeout_secs: int = 0, + project_path: str = "", ) -> CronJob: """Add a new job. Provide one of ``every_secs``, ``at_ts``, or ``cron_expr``. @@ -2678,6 +2694,7 @@ def add_job( minimal_context=minimal_context, timeout=timeout, timeout_secs=timeout_secs, + project_path=project_path, ) self._persist_add_locked(job) self._arm_timer() @@ -2711,8 +2728,17 @@ async def add_job_if_absent_async( registrars (e.g. a CLI enable racing gateway boot) cannot both observe the name as absent and persist duplicates. Returns None when a matching job already exists. + + Like :meth:`add_job_async`, a non-empty ``project_path`` kwarg is + resolved via :meth:`_validate_project_path_async` (a worker-thread + offload) before the on-loop build, since ``_build_job`` cannot run + that syscall-bearing check inline without stalling the loop. """ - job = self._build_job(**kwargs) + project_path = kwargs.get("project_path") or "" + resolved_project_path = ( + await self._validate_project_path_async(project_path) if project_path else None + ) + job = self._build_job(**kwargs, _resolved_project_path=resolved_project_path) persisted = await asyncio.to_thread(self._persist_add_if_absent_locked, predicate, job) if not persisted: return None @@ -2807,6 +2833,8 @@ def _build_job( minimal_context: bool = False, timeout: int = 0, timeout_secs: int = 0, + project_path: str = "", + _resolved_project_path: str | None = None, ) -> CronJob: """Validate inputs and construct the :class:`CronJob` (no I/O, no lock). @@ -2825,6 +2853,26 @@ def _build_job( transaction. This closes a create-then-mutate-then-unlocked-``_save`` window (two concurrent creates could otherwise interleave at the ``await`` and the unlocked save could clobber the other request's job). + + ``project_path``, when non-empty, must be an absolute, existing, + non-sensitive directory — validated HERE (same shape as + ``chat_folders._validate_project_dir``) so every create path shares one + check and a job is never persisted pointing at a path that was already + invalid at creation time. A path that later disappears is NOT + re-validated on every wake; that is a run-time condition surfaced on + the run record, not a reason to refuse the job outright. + + ``_resolved_project_path`` is an internal-only escape hatch for the + async build paths: this method is genuinely "no I/O" for every OTHER + field, but ``project_path`` validation does real filesystem syscalls + (``os.path.realpath``, ``os.path.isdir``), which a caller on the event + loop must not run inline. :meth:`add_job_async` and + :meth:`add_job_if_absent_async` resolve ``project_path`` via + :meth:`_validate_project_path_async` (a worker-thread offload) BEFORE + calling this method, then pass the result through here so the sync + resolution is skipped. Sync callers (:meth:`add_job`, + :meth:`add_job_if_absent`) never set this and get the original + on-the-spot validation. """ valid_approval_modes = ("", "auto") if approval_mode not in valid_approval_modes: @@ -2860,9 +2908,15 @@ def _build_job( "command": command, "script": script, "timezone": timezone, + "project_path": project_path, }, required=frozenset({"name", "message"}), ) + resolved_project_path = "" + if _resolved_project_path is not None: + resolved_project_path = _resolved_project_path + elif project_path: + resolved_project_path = self._validate_project_path(project_path) if timeout_secs and not 1 <= int(timeout_secs) <= 86400: raise ValueError(f"timeout_secs must be within 1..86400, got {timeout_secs}") if timeout_secs and (command or script): @@ -2929,8 +2983,60 @@ def _build_job( minimal_context=minimal_context, timeout=timeout, timeout_secs=int(timeout_secs) if timeout_secs else _JOB_TIMEOUT_SECS, + project_path=resolved_project_path, ) + @staticmethod + def _validate_project_path(raw: str) -> str: + """Validate a cron job's ``project_path``. Returns the resolved path. + + Mirrors ``chat_folders._validate_project_dir``'s shape (absolute, + resolved, non-sensitive, existing directory) so a cron's project + binding is held to the same bar as a chat folder's. Raises + ``ValueError`` on any failure — this runs inside ``_build_job``, + the single locked create chokepoint, so a rejected path never reaches + disk as part of a job. + + The realpath/sensitivity/existing-directory core is + :func:`security.resolve_project_path`; the absolute/``~`` gate, the + raise-on-failure shape, and the SEL denial log are this create surface's. + + Synchronous filesystem I/O (``os.path.realpath``, ``os.path.isdir``) — + callers on the event loop MUST NOT call this directly. Use + :meth:`_validate_project_path_async` instead, which offloads the same + check to a worker thread; an unavailable NFS/FUSE-backed path here + would otherwise stall the gateway's single event loop. + """ + if not os.path.isabs(raw) and not raw.startswith("~"): + raise ValueError("project_path must be an absolute path") + verdict = resolve_project_path(raw) + if verdict.sensitive: + sel.sel().log_api_access( + caller="cron", + operation="cron.project_path", + outcome="denied", + source="cron", + resources=verdict.resolved, + error="sensitive path", + ) + raise ValueError("project_path refers to a sensitive path") + if not verdict.is_dir: + raise ValueError("project_path must be an existing directory") + return verdict.resolved + + @classmethod + async def _validate_project_path_async(cls, raw: str) -> str: + """Event-loop-safe :meth:`_validate_project_path`. + + Offloads the same absolute/resolved/non-sensitive/existing-directory + check to a worker thread via ``asyncio.to_thread`` — the async build + paths (:meth:`add_job_async`, :meth:`add_job_if_absent_async`) resolve + ``project_path`` through this BEFORE calling :meth:`_build_job`, then + pass the already-resolved path in via ``_resolved_project_path`` so + ``_build_job`` does not repeat the syscalls on the loop. + """ + return await asyncio.to_thread(cls._validate_project_path, raw) + def _persist_add_locked(self, job: CronJob) -> None: """Lock/reload/append/save for a new job — the thread-safe disk core. @@ -2982,24 +3088,36 @@ async def add_job_async( timeout_secs: int = 0, source_preset: str = "", source_template_prompt: str = "", + project_path: str = "", ) -> CronJob: """Event-loop-safe :meth:`add_job`: the lock+save runs off the loop. The gateway's aiohttp/Slack handlers run on the sole asyncio event loop; calling the sync :meth:`add_job` there parks the loop in the bounded lock - spin under contention. This builds+validates on the loop (no I/O), - offloads the lock+persist to a worker thread via ``asyncio.to_thread`` - (the disk core is thread-safe — flock on separate fds mutually excludes - in-process too), then re-arms the timer back on the loop. Raises + spin under contention. This builds+validates on the loop, offloads the + lock+persist to a worker thread via ``asyncio.to_thread`` (the disk core + is thread-safe — flock on separate fds mutually excludes in-process + too), then re-arms the timer back on the loop. Raises :class:`CronStoreBusy` (retryable) on sustained contention; the public boundaries translate it to a clean 409 / structured error. + ``project_path`` is the one field in ``_build_job`` that does real + filesystem I/O (``os.path.realpath``, ``os.path.isdir``), so it is + resolved here via :meth:`_validate_project_path_async` — a worker-thread + offload — BEFORE the on-loop build, and the already-resolved value is + passed through so ``_build_job`` does not repeat the syscalls on the + loop. An unavailable NFS/FUSE-backed path would otherwise stall the + gateway's single event loop for the duration of the stat. + Optional presentation/routing fields (``agent_id``, ``model``, ``silent``, ``timezone``, ``strict_schedule``, ``hide_in_chat``) are applied during the single locked build+persist so callers never need a follow-up unlocked ``_save()`` (which could race a concurrent create and drop a job). """ + resolved_project_path = ( + await self._validate_project_path_async(project_path) if project_path else None + ) job = self._build_job( name, message, @@ -3030,6 +3148,8 @@ async def add_job_async( minimal_context=minimal_context, timeout=timeout, timeout_secs=timeout_secs, + project_path=project_path, + _resolved_project_path=resolved_project_path, ) # Dashboard-only template provenance. Set on the freshly-built job # BEFORE the off-loop persist -- the object has no other reference yet, @@ -3095,6 +3215,17 @@ def _update_job_locked(self, job_id: str, **kwargs: Any) -> CronJob | None: # instead of resurrecting state the operator withdrew. expect_active = kwargs.pop("expect_secret_env", None) expect_active_pin = kwargs.pop("expect_secret_env_pin", None) + # Same shape again for the owner-authorization decision itself: the + # dashboard handler reads the job's `project_path` OUTSIDE this lock + # to decide whether the caller needs owner authorization, then applies + # the update separately here. A concurrent owner bind/unbind landing + # in that gap would let a non-owner's already-authorized (on the + # stale snapshot) update or run execute against a project it was + # never checked against -- the multi-human TOCTOU this field exists + # to close. `_UNSET` (not `None`) is the "no precondition" sentinel, + # since the empty string is itself a meaningful expected value + # (unbound) that must be distinguishable from "the caller didn't ask". + expect_project_path = kwargs.pop("expect_project_path", _UNSET) with self._file_lock(): self._sync_for_write() for job in self._jobs: @@ -3110,6 +3241,8 @@ def _update_job_locked(self, job_id: str, **kwargs: Any) -> CronJob | None: raise CronPendingMismatch("active grant changed concurrently") if expect_active_pin is not None and job.secret_env_pin != expect_active_pin: raise CronPendingMismatch("active grant pin changed concurrently") + if expect_project_path is not _UNSET and job.project_path != expect_project_path: + raise CronPendingMismatch("project binding changed concurrently") # Validate approval_mode if provided if "approval_mode" in kwargs: valid_approval_modes = ("", "auto") @@ -3157,6 +3290,12 @@ def _update_job_locked(self, job_id: str, **kwargs: Any) -> CronJob | None: if "timezone" in kwargs and kwargs["timezone"]: if not is_valid_timezone(kwargs["timezone"]): raise ValueError(f"Invalid timezone: {kwargs['timezone']!r}") + # Same bar as add_job/_build_job: an updated project_path must + # be absolute, resolved, non-sensitive, and an existing + # directory. An empty string is a valid update (clears the + # binding back to global-agent-only) and skips this check. + if "project_path" in kwargs and kwargs["project_path"]: + kwargs["project_path"] = self._validate_project_path(kwargs["project_path"]) if "skip_dates" in kwargs and kwargs["skip_dates"]: for _d in kwargs["skip_dates"]: if not is_valid_skip_date(_d): @@ -3282,6 +3421,8 @@ def _update_job_locked(self, job_id: str, **kwargs: Any) -> CronJob | None: job.skip_dates = kwargs["skip_dates"] or [] if "timezone" in kwargs: job.timezone = kwargs["timezone"] or "" + if "project_path" in kwargs: + job.project_path = kwargs["project_path"] or "" if "strict_schedule" in kwargs: job.strict_schedule = bool(kwargs["strict_schedule"]) if "persistent_session" in kwargs: @@ -4172,33 +4313,51 @@ async def owner_keys_async(self) -> set[str]: """ return await asyncio.to_thread(self._owner_keys_locked) - def enable_job(self, job_id: str, enabled: bool = True) -> bool: + def enable_job( + self, job_id: str, enabled: bool = True, expect_project_path: str | object = _UNSET + ) -> bool: """Enable or disable a job by ID. + ``expect_project_path``, when passed, closes the same TOCTOU the + update path's precondition of the same name closes (see + :meth:`update_job_async`): re-verified against the freshly reloaded + record UNDER the lock, atomically with the actual toggle, rather than + trusting a snapshot the caller read separately before deciding + whether the request needed owner authorization. + Raises :class:`CronStoreBusy` on lock contention; see :meth:`enable_job_async` for the event-loop-safe variant. """ - ok = self._enable_job_locked(job_id, enabled) + ok = self._enable_job_locked(job_id, enabled, expect_project_path) if ok: self._arm_timer() return ok - async def enable_job_async(self, job_id: str, enabled: bool = True) -> bool: + async def enable_job_async( + self, job_id: str, enabled: bool = True, expect_project_path: str | object = _UNSET + ) -> bool: """Event-loop-safe :meth:`enable_job`: the lock+save runs off the loop. Raises :class:`CronStoreBusy` (retryable) on sustained contention. """ - ok = await asyncio.to_thread(self._enable_job_locked, job_id, enabled) + ok = await asyncio.to_thread(self._enable_job_locked, job_id, enabled, expect_project_path) if ok: self._arm_timer() return ok - def _enable_job_locked(self, job_id: str, enabled: bool = True) -> bool: + def _enable_job_locked( + self, job_id: str, enabled: bool = True, expect_project_path: str | object = _UNSET + ) -> bool: """Lock/reload/mutate/save core of :meth:`enable_job` (no timer work).""" with self._file_lock(): self._sync_for_write() for job in self._jobs: if job.id == job_id: + if ( + expect_project_path is not _UNSET + and job.project_path != expect_project_path + ): + raise CronPendingMismatch("project binding changed concurrently") job.user_paused = not enabled job.enabled = enabled # Re-enabling clears an execution auto-pause; without this a @@ -4334,8 +4493,25 @@ def set_refresh_callback(self, cb: Any) -> None: """Set the dashboard refresh callback.""" self._push_refresh = cb - async def run_job(self, job_id: str) -> bool: - """Manually trigger a job via _run_job_isolated (records history).""" + async def run_job(self, job_id: str, expect_project_path: str | object = _UNSET) -> bool: + """Manually trigger a job via _run_job_isolated (records history). + + ``expect_project_path``, when passed, closes the same TOCTOU the + update/enable paths' precondition of the same name closes (see + :meth:`update_job_async`): the caller's owner-authorization decision + was made against a snapshot read BEFORE this call, and this method + re-syncs its OWN fresh snapshot from disk independently -- an owner + binding the project in the gap between the two reads would otherwise + let an already-authorized (against the stale, unbound snapshot) + non-owner's trigger execute against the newly-bound project. A + mismatch REFUSES the run and returns ``False``, the same silent-skip + contract as "job not found" and "already running" below -- unlike + the update/enable preconditions, this method's caller (the REST + handler) dispatches it fire-and-forget via ``asyncio.create_task`` + and has already returned its HTTP response by the time this runs, + so there is no synchronous caller left to receive a raised + exception. + """ # Refresh the store off the loop, then resolve + claim on the loop. # # The locked _sync() + snapshot runs in a worker thread (_synced_snapshot @@ -4356,6 +4532,16 @@ async def run_job(self, job_id: str) -> bool: job = next((j for j in snapshot if j.id == job_id), None) if not job: return False + if expect_project_path is not _UNSET and job.project_path != expect_project_path: + sel.sel().log_api_access( + caller="cron", + operation="cron.run.project_bound_job", + outcome="denied", + source="cron", + resources=job_id, + error="project binding changed concurrently", + ) + return False if job.id in self._executing: return False self._job_run_meta[job.id] = (time.time(), "manual") @@ -6043,6 +6229,7 @@ def _save(self) -> None: "consecutive_failures": j.consecutive_failures, "skip_dates": j.skip_dates, "timezone": j.timezone, + "project_path": j.project_path, "persistent_session": j.persistent_session, "minimal_context": j.minimal_context, "hide_in_chat": j.hide_in_chat, diff --git a/src/kiro_crew/dashboard/handlers/agents.py b/src/kiro_crew/dashboard/handlers/agents.py index ac000f04a3d..4e5da89967c 100644 --- a/src/kiro_crew/dashboard/handlers/agents.py +++ b/src/kiro_crew/dashboard/handlers/agents.py @@ -54,12 +54,7 @@ from kiro_crew.agent_sdk.provider_identity import is_claude_code from kiro_crew.apps.bridges import _mcp_lock as _agent_file_lock from kiro_crew.apps.bridges import _registration_source -from kiro_crew.apps.manager import ( - INSTALLED_META_FILENAME, - app_dir, - app_enabled_state, - apps_dir, -) +from kiro_crew.apps.manager import INSTALLED_META_FILENAME, app_dir, app_enabled_state, apps_dir from kiro_crew.atomic_write import replace_with_retry from kiro_crew.config.loader import ( ConfigReadError, @@ -102,11 +97,11 @@ MAX_AGENT_SKILLS, _capability_manager, _read_session_key, - active_project_dir, agent_skill_keys, agent_skill_views, apply_skill_mapping, read_bounded_json, + requesting_slot_project, ) from kiro_crew.dashboard.handlers.discover import _redact_external from kiro_crew.dashboard.kiro_readiness import reject_if_kiro_unverified @@ -137,6 +132,8 @@ scrub_agent_subprocess_env, wrap_argv, ) +from kiro_crew.security import resolve_project_path +from kiro_crew.sel import sel from kiro_crew.validation import _AGENT_NAME_RE _MODEL_LIST_STDERR_TAIL_CHARS = 1000 @@ -1161,10 +1158,7 @@ async def api_agent_config(request: web.Request) -> web.Response: # decision being current; and one call, not two, so the filter is # never applied to a config it already filtered. - from kiro_crew.dashboard.handlers.mcp import ( - _get_mcp_lock, - _offload_config_write, - ) + from kiro_crew.dashboard.handlers.mcp import _get_mcp_lock, _offload_config_write # PHASE 2 ── commit. Both locks are acquired ahead of every durable # write, so a cancellation at the (unbounded, contended) flock wait @@ -3816,6 +3810,26 @@ def _agent_roster_row( } +def _resolve_agents_project_path(raw: str) -> tuple[str, bool]: + """Resolve+validate a raw ``project_path`` query value off the event loop. + + Returns ``(resolved_path, denied)``: ``denied=True`` means the path was + rejected as sensitive (the caller logs the SEL denial itself, since this + function runs in a worker thread and must not touch ``sel()`` there). + ``resolved_path`` is ``""`` when the path is neither denied nor a valid + existing directory. The realpath/sensitivity/isdir core is + :func:`security.resolve_project_path`, shared with + :meth:`CronService._validate_project_path`; no ``~``/absolute-path gate here, + since a query param is not held to the cron create-time bar. + """ + verdict = resolve_project_path(raw) + if verdict.sensitive: + return verdict.resolved, True + if verdict.is_dir: + return verdict.resolved, False + return "", False + + async def api_kirocrew_agents(request: web.Request) -> web.Response: """GET /api/agents — list all Kiro Crew agent definitions, most-used first. @@ -3857,7 +3871,101 @@ async def api_kirocrew_agents(request: web.Request) -> web.Response: # Project rows come from a directory scan, so it runs on the discovery # pool — same rule as every other agent listing: no filesystem I/O on the # event loop. Failure costs only the project rows, never the roster. - project_dir = active_project_dir(state, _read_session_key(request)) if state else "" + # + # Two sources of a project scope, in precedence order: + # 1. sessionKey → the live in-memory slot's own .project. This is the + # ONLY source for a real chat session, and it must win when present: + # the slot is the thing that will actually run in that directory, so + # its own project is authoritative over anything a caller separately + # claims. Deliberately `requesting_slot_project`, NOT + # `active_project_dir`: the latter's step 2 falls back to "the single + # project shared by every open slot" for ANY session key, including + # the dashboard-wide `dashboard:ui` sentinel every client call sends + # when it has no real chat slot to name (see api/client.ts's `_sk` + # fallback). That fallback silently won here whenever exactly one + # chat tab happened to have a project bound, hijacking every caller + # of THIS raw-path fallback (the cron job form's picker) regardless + # of which project it actually asked for — confirmed live: a + # request for a real `project_path` with `ea-dev.json` on disk came + # back with zero project rows because an unrelated open chat slot's + # project won instead. `requesting_slot_project` answers only "is + # THIS session key a real, specific slot with its own project" and + # returns `None` for the sentinel/empty case, which is exactly the + # signal this fallback needs — a raw project_path must be shadowed + # only by a session that is ACTUALLY that project's chat, never by + # "some other tab happens to be open on one project right now." + # 2. project_path query param → a raw path with NO live slot behind it + # (e.g. the Schedule page's job form, populating a project-scoped + # agent picker for a cron job that has no session to key off of). + # Falls back to this ONLY when sessionKey resolved to no project, so + # a caller cannot override a real slot's project by also passing a + # stale project_path. + slot_project = requesting_slot_project(state, _read_session_key(request)) if state else None + project_dir = str(slot_project) if slot_project else "" + if not project_dir and redact: + # A non-owner passing project_path gets no fallback and no error -- + # audited here for the same reason the sensitive-path denial below + # is: a silently-ignored parameter on an owner-gated fallback is + # exactly the shape a probe for the gate's edges looks like, and the + # read/write sides of this owner check should audit symmetrically. + raw_project_path = (request.query.get("project_path") or "").strip() + if raw_project_path: + sel().log_api_access( + caller="dashboard", + operation="api_kirocrew_agents.project_path", + outcome="denied", + source="dashboard", + resources=raw_project_path, + error="not owner", + ) + if not project_dir and not redact: + # Owner-gated: this fallback lets a caller with no live slot name an + # arbitrary absolute path, and the only checks on that path + # (realpath/is_sensitive_path/isdir) guard credential homes, not the + # multi-human authorization boundary -- an allow-listed messaging + # user's non-owner `!dashboard` token (app == "", which sails through + # every app-token check) could otherwise probe any non-sensitive + # directory on the host and read back its project agent names via + # `_agent_roster_row`. `redact` (computed above from the same + # `is_owner_dashboard_request`) is already this function's one + # deny-by-default owner signal, so gating on it here keeps the read + # and write sides of this fallback agreeing on who the owner is. The + # Schedule job form -- the only legitimate caller -- always presents + # an owner dashboard token, so this does not narrow the feature. + raw_project_path = (request.query.get("project_path") or "").strip() + if raw_project_path: + # Same off-loop treatment as CronService._validate_project_path_async: + # realpath/is_sensitive_path/isdir are real filesystem syscalls, and + # this handler runs on the gateway's sole event loop, so a caller + # naming an unavailable NFS/FUSE path here would otherwise stall + # every chat turn and the liveness heartbeat until the watchdog + # kills the process -- the exact class of bug the adjacent comment + # above ("no filesystem I/O on the event loop") already warns about, + # and the very next block below already offloads its own scan via + # this same executor. + resolved, denied = await asyncio.get_running_loop().run_in_executor( + discovery_executor(), + _resolve_agents_project_path, + raw_project_path, + ) + if denied: + sel().log_api_access( + caller="dashboard", + operation="api_kirocrew_agents.project_path", + outcome="denied", + source="dashboard", + resources=resolved, + error="sensitive path", + ) + elif resolved: + sel().log_api_access( + caller="dashboard", + operation="api_kirocrew_agents.project_path", + outcome="allowed", + source="dashboard", + resources=resolved, + ) + project_dir = resolved if project_dir: try: project_names = await asyncio.get_running_loop().run_in_executor( diff --git a/src/kiro_crew/dashboard/handlers/cron.py b/src/kiro_crew/dashboard/handlers/cron.py index 4f849f2c91b..54c148c15f5 100644 --- a/src/kiro_crew/dashboard/handlers/cron.py +++ b/src/kiro_crew/dashboard/handlers/cron.py @@ -21,6 +21,7 @@ from kiro_crew.config.loader import config_dir from kiro_crew.context import ContextBuilder from kiro_crew.cron import ( + _UNSET, CronPendingMismatch, CronStoreBusy, CronStoreUnreadable, @@ -586,10 +587,37 @@ async def api_crons_create(request: web.Request) -> web.Response: body, "source_template_prompt", max_len=MAX_CRON_MESSAGE ) member_id = validate_string_field(body, "member_id", max_len=MAX_SHORT_STRING) + project_path = validate_string_field(body, "project_path", max_len=MAX_SHORT_STRING) except ValidationError as exc: return web.json_response({"error": str(exc)}, status=400) if not name or not message: return web.json_response({"error": "name and message required"}, status=400) + # project_path binds the job's agent to an arbitrary cwd at fire time, so + # it carries the same owner-authorization boundary as the agents-roster + # `project_path` query param (handlers/agents.py): an allow-listed + # non-owner dashboard token (app == "", which sails through every app- + # token check) must not be able to create a job that later reads and + # returns another project's files just by naming its path. Denied and + # audited rather than silently ignored, matching that read-side gate. + if project_path and not is_owner_dashboard_request(request): + try: + _sel().log_api_access( + caller="dashboard", + operation="cron.create.project_path", + outcome="denied", + source="dashboard", + resources=project_path, + error="not owner", + ) + except Exception: + logger.debug("SEL logging failed for cron create project_path denial", exc_info=True) + return web.json_response( + { + "error": "project_path requires owner authorization", + "code": "project_path_owner_required", + }, + status=403, + ) every = body.get("every") if not every and not cron_expr and schedule: # Treat schedule string as cron expr if 5-field, else as interval @@ -672,6 +700,7 @@ async def api_crons_create(request: web.Request) -> web.Response: # own copy. Both "" for a blank create. Never gate execution. "source_preset": (source_preset or ""), "source_template_prompt": (source_template_prompt or ""), + "project_path": (project_path or ""), } if approval_mode: add_kwargs["approval_mode"] = approval_mode @@ -824,6 +853,44 @@ async def api_cron_update(request: web.Request) -> web.Response: if body_err is not None: return body_err assert body is not None # read_bounded_json returns (dict, None) on success + # A project-bound job's message/agent/schedule can be rewritten and later + # fired with `job.project_path` as its execution cwd -- gating only the + # `project_path` field itself (below) protected the BINDING but not the + # already-bound JOB: a non-owner could edit an owner-bound job's message + # (ungated) or trigger it (api_cron_run, also ungated) and read that + # project's files without ever touching project_path. So this checks the + # PERSISTED job's binding up front, before any field is applied, and + # requires owner authorization for the update AS A WHOLE whenever a + # binding already exists -- caught by review. The case where THIS body + # is newly requesting a project_path is checked separately below, AFTER + # validate_string_field, so a malformed (non-string) project_path from a + # non-owner still surfaces its proper 400 rather than being masked by + # this 403 -- `existing_job.project_path` is always a validated string + # already, so no such ordering hazard applies to it. + existing_job = await state.crons.get_job_async(job_id) + if existing_job is None: + return web.json_response({"error": "job not found", "code": "job_not_found"}, status=404) + if existing_job.project_path and not is_owner_dashboard_request(request): + try: + _sel().log_api_access( + caller="dashboard", + operation="cron.update.project_bound_job", + outcome="denied", + source="dashboard", + resources=job_id, + error="not owner", + ) + except Exception: + logger.debug( + "SEL logging failed for cron update project-bound-job denial", exc_info=True + ) + return web.json_response( + { + "error": "updating a project-bound job requires owner authorization", + "code": "project_bound_job_owner_required", + }, + status=403, + ) kwargs: dict[str, Any] = {} for key in ( "name", @@ -914,10 +981,71 @@ async def api_cron_update(request: web.Request) -> web.Response: safe_tz, _ = redact_credentials(redact_exfiltration_urls(tz_val)[0]) return web.json_response({"error": f"invalid timezone: {safe_tz!r}"}, status=400) kwargs["timezone"] = tz_val + if "project_path" in body: + # Same validator as create (isinstance(str) + sanitize + length cap) + # so PATCH cannot diverge from POST: a non-string JSON project_path + # (array/object/number) would otherwise reach `.strip()` unguarded + # here and raise AttributeError -> HTTP 500 instead of a clean 400. + # Deliberately validated BEFORE the owner check below: a malformed + # non-owner request must still surface its proper 400, not a 403 + # that masks the real problem. + try: + validated_project_path = validate_string_field( + body, "project_path", max_len=MAX_SHORT_STRING + ) + except ValidationError as exc: + return web.json_response( + {"error": str(exc), "code": "invalid_project_path"}, status=400 + ) + # The job-level gate above already covers a job with an EXISTING + # binding. This covers the other half: a non-owner newly SETTING a + # binding on a job that has none yet -- both setting and clearing + # (an empty string here) are privileged mutations of this field. + if validated_project_path and not is_owner_dashboard_request(request): + try: + _sel().log_api_access( + caller="dashboard", + operation="cron.update.project_path", + outcome="denied", + source="dashboard", + resources=validated_project_path, + error="not owner", + ) + except Exception: + logger.debug( + "SEL logging failed for cron update project_path denial", exc_info=True + ) + return web.json_response( + { + "error": "project_path requires owner authorization", + "code": "project_path_owner_required", + }, + status=403, + ) + kwargs["project_path"] = validated_project_path if not kwargs: return web.json_response({"error": "no fields to update"}, status=400) + # The owner-authorization decisions above were made against a SNAPSHOT + # (`existing_job`) read outside any lock; the actual mutation below + # acquires the store's lock separately. A concurrent owner bind/unbind + # landing in that gap would let a non-owner's already-cleared request + # execute against a binding it was never actually checked against -- + # closed by re-verifying the same field atomically, under the lock, + # immediately before the write: an owner's own request needs no such + # guard (their authorization does not depend on the binding's value). + if not is_owner_dashboard_request(request): + kwargs["expect_project_path"] = existing_job.project_path try: job = await state.crons.update_job_async(job_id, **kwargs) + except CronPendingMismatch: + return web.json_response( + { + "error": "the job's project binding changed after it was checked — " + "reload and try again", + "code": "stale_project_binding", + }, + status=409, + ) except CronStoreBusy: return web.json_response(_CRON_BUSY_BODY, status=_CRON_BUSY_STATUS) except CronStoreUnreadable as exc: @@ -1634,6 +1762,31 @@ async def api_cron_run(request: web.Request) -> web.Response: job = await state.crons.get_job_async(job_id) if not job: return web.json_response({"error": "job not found"}, status=404) + # A project-bound job fires with `job.project_path` as its execution + # cwd, reading and potentially returning that project's files. Manual + # trigger has no owner gate elsewhere in this route -- without this, a + # non-owner allow-listed dashboard token could fire an owner-bound job + # on demand and exfiltrate the project's data, same class of finding as + # the PATCH-time gate above (`api_cron_update`) but for the run path. + if job.project_path and not is_owner_dashboard_request(request): + try: + _sel().log_api_access( + caller="dashboard", + operation="cron.run.project_bound_job", + outcome="denied", + source="dashboard", + resources=job_id, + error="not owner", + ) + except Exception: + logger.debug("SEL logging failed for cron run project-bound-job denial", exc_info=True) + return web.json_response( + { + "error": "triggering a project-bound job requires owner authorization", + "code": "project_bound_job_owner_required", + }, + status=403, + ) # Reject if a run is already in flight. Overwriting _running_tasks[job_id] # would orphan the prior task's handle (it could no longer be # tracked/cancelled/joined) and allow overlapping duplicate runs. The @@ -1644,7 +1797,18 @@ async def api_cron_run(request: web.Request) -> web.Response: # because the guard and the assignment are not separated by an await.) if job_id in state.crons._running_tasks or state.crons.is_running(job_id): return web.json_response({"error": "job is already running"}, status=409) - task = asyncio.create_task(state.crons.run_job(job_id)) # type: ignore[arg-type] + # The owner-authorization decision above used a snapshot read outside any + # lock; `run_job` independently re-syncs its OWN fresh snapshot from disk + # once the task actually starts, well after this handler has returned. + # An owner binding the project in that gap would let this already- + # authorized (against the stale, unbound snapshot) non-owner's dispatch + # execute against the newly-bound project -- closed by passing the same + # field forward so `run_job` can re-verify it itself; an owner's own + # request needs no such guard. + run_kwargs: dict[str, Any] = {} + if not is_owner_dashboard_request(request): + run_kwargs["expect_project_path"] = job.project_path + task = asyncio.create_task(state.crons.run_job(job_id, **run_kwargs)) # type: ignore[arg-type] state.crons._running_tasks[job_id] = task # type: ignore[assignment] def _on_done(t: asyncio.Task, _jid: str = job_id) -> None: # type: ignore[type-arg] @@ -1742,8 +1906,59 @@ async def api_cron_enable(request: web.Request) -> web.Response: return body_err assert body is not None # read_bounded_json returns (dict, None) on success enabled = body.get("enabled", True) + # Re-enabling an owner-disabled project-bound job hands it back to the + # scheduler, which fires it against `job.project_path` the same as a + # manual trigger -- unlike run, this route had NO owner gate at all + # (create/update/run all do, per the same class of finding). Only the + # RE-ENABLE direction needs the check: disabling one's own or anyone + # else's job stops execution rather than starting it, so it carries no + # equivalent exfiltration risk. + if enabled: + job = await state.crons.get_job_async(job_id) + if job and job.project_path and not is_owner_dashboard_request(request): + try: + _sel().log_api_access( + caller="dashboard", + operation="cron.enable.project_bound_job", + outcome="denied", + source="dashboard", + resources=job_id, + error="not owner", + ) + except Exception: + logger.debug( + "SEL logging failed for cron enable project-bound-job denial", + exc_info=True, + ) + return web.json_response( + { + "error": "enabling a project-bound job requires owner authorization", + "code": "project_bound_job_owner_required", + }, + status=403, + ) + # Same TOCTOU close as api_cron_update: the owner-authorization + # decision above used a snapshot read outside any lock, so + # re-verify the same field atomically under the lock right before + # the actual toggle -- an owner's own request needs no such guard. + expect_project_path = ( + job.project_path if job and not is_owner_dashboard_request(request) else _UNSET + ) + else: + expect_project_path = _UNSET try: - ok = await state.crons.enable_job_async(job_id, enabled=enabled) + ok = await state.crons.enable_job_async( + job_id, enabled=enabled, expect_project_path=expect_project_path + ) + except CronPendingMismatch: + return web.json_response( + { + "error": "the job's project binding changed after it was checked — " + "reload and try again", + "code": "stale_project_binding", + }, + status=409, + ) except CronStoreBusy: return web.json_response(_CRON_BUSY_BODY, status=_CRON_BUSY_STATUS) except CronStoreUnreadable as exc: @@ -2659,6 +2874,14 @@ async def api_crons(request: web.Request) -> web.Response: ), "script": redact_credentials(redact_exfiltration_urls(j.script or "")[0])[0] or None, "command": redact_credentials(redact_exfiltration_urls(j.command or "")[0])[0] or None, + # The operating folder a project-scoped job resolves its agent and + # cwd against. The Schedule page reopens a job by re-parsing THIS + # response (JobForm.parseJobDefaults reads `job.project_path`), so + # this field's absence here — not a frontend bug — is what would + # leave the "Operating folder" field blank on every edit despite + # the value being correctly persisted and used at fire time. + "project_path": redact_credentials(redact_exfiltration_urls(j.project_path or "")[0])[0] + or None, # Grant metadata only — env-var names and vault secret NAMES; # plaintext values never leave the vault. Owner-only even so: a # non-owner dashboard token (an allowed Slack user's !dashboard diff --git a/src/kiro_crew/security/__init__.py b/src/kiro_crew/security/__init__.py index 51912606a80..7204ac47299 100644 --- a/src/kiro_crew/security/__init__.py +++ b/src/kiro_crew/security/__init__.py @@ -329,6 +329,7 @@ MAX_SCANNABLE_COMMAND_CHARS, MAX_SCANNABLE_SOURCE_BODY_CHARS, PathResolutionStalled, + ProjectPathVerdict, _candidate_forms, _expanded_env_root, _home_dir_targets, @@ -360,6 +361,7 @@ is_sensitive_path, is_sensitive_write_path, path_contains_sensitive, + resolve_project_path, sandbox_credential_targets, sensitive_home_dirs, write_protected_home_paths, @@ -1151,9 +1153,7 @@ def _deny_segment_views(segment: str, emit_self: bool = True) -> tuple[str, ...] seen_views.add(candidate) views.append(candidate) joined_here: set[str] = set() - payloads = _nested_shell_payloads( - tokens, allow_join=allow_join, joined_out=joined_here - ) + payloads = _nested_shell_payloads(tokens, allow_join=allow_join, joined_out=joined_here) programs = _argv_programs(tokens) if payloads else [] # Both values below read ONLY ``tokens``, which is fixed for this # whole walk, so they are charged ONCE here instead of once per @@ -1402,9 +1402,7 @@ def _reason( agent cannot diagnose at all. The span is the whole subject because a floor decides on the argv's SHAPE rather than at an offset. """ - diagnostic = ( - refusal_diagnostic(rule, component, tool_name) if rule and component else None - ) + diagnostic = refusal_diagnostic(rule, component, tool_name) if rule and component else None return _deny_reason( matched, reason_notes, note_override=note_override, diagnostic=diagnostic ) diff --git a/src/kiro_crew/security/_exports.py b/src/kiro_crew/security/_exports.py index 74e52bf118a..2fd0dab201d 100644 --- a/src/kiro_crew/security/_exports.py +++ b/src/kiro_crew/security/_exports.py @@ -49,6 +49,7 @@ "OAuthUrlShapeProfile", "Path", "PathResolutionStalled", + "ProjectPathVerdict", "REDACTED_CREDENTIAL_TAG", "REFUSAL_DIAGNOSTIC_PREFIX", "RefusalDiagnostic", @@ -536,6 +537,7 @@ "refusal_diagnostic", "refusal_span_shape", "resource_limit_spec", + "resolve_project_path", "sandbox_credential_targets", "sanitized_oauth_endpoint", "scan_exfiltration_urls", diff --git a/src/kiro_crew/security/paths.py b/src/kiro_crew/security/paths.py index 2d300ed6a54..795f88cae85 100644 --- a/src/kiro_crew/security/paths.py +++ b/src/kiro_crew/security/paths.py @@ -2381,6 +2381,47 @@ def is_sensitive_path(path_str: str, base_dir: str | None = None) -> bool: ) or _is_keystone_publish_artifact(path_str, base_dir) +class ProjectPathVerdict(NamedTuple): + """The shared realpath -> sensitivity -> existing-directory verdict. + + ``resolved`` is ``realpath(expanduser(raw))`` -- always populated, so a + caller that logs a denial can name the path it rejected. ``sensitive`` and + ``is_dir`` are the two independent axes a project-path binding is held to + everywhere (a cron job's ``project_path``, a chat folder's ``project_dir``, + the ``/api/agents`` query param). What each caller DOES with them differs -- + raise, return an error string, or return a bool, and which side logs the SEL + denial -- so this returns the raw verdict and leaves the shape to the caller. + The absolute/``~`` gate is deliberately NOT here: a query param is not held + to it, only the create-time surfaces are. + + Synchronous filesystem I/O (``realpath``, ``isdir``); callers on the event + loop MUST run it via a worker thread, never inline. + """ + + resolved: str + sensitive: bool + is_dir: bool + + +def resolve_project_path(raw: str) -> ProjectPathVerdict: + """Resolve *raw* and report the shared project-path verdict. + + The one place the ``realpath(expanduser)`` -> :func:`is_sensitive_path` -> + ``isdir`` core lives, so the cron and agents validators cannot drift + apart. ``chat_folders._validate_project_dir`` still keeps its own + independent copy of this core -- not yet consolidated onto this + function, so it is not covered by this claim. See + :class:`ProjectPathVerdict` for what each field means and why the + return shape (and the absolute-path gate) stays with the caller. + """ + resolved = os.path.realpath(os.path.expanduser(raw)) + return ProjectPathVerdict( + resolved=resolved, + sensitive=is_sensitive_path(resolved), + is_dir=os.path.isdir(resolved), + ) + + def path_contains_sensitive(dir_str: str, base_dir: str | None = None) -> bool: """Return True if a read+write-sensitive location lies UNDER *dir_str*. diff --git a/src/kiro_crew/slack/gateway.py b/src/kiro_crew/slack/gateway.py index e11b478e15d..981b9292187 100644 --- a/src/kiro_crew/slack/gateway.py +++ b/src/kiro_crew/slack/gateway.py @@ -52,6 +52,7 @@ shutdown_event, ) from kiro_crew.acp.client import AcpError, AcpProcessDied +from kiro_crew.agent_discovery import warm_project_agent_names from kiro_crew.agent_sdk import AgentTurnUsage from kiro_crew.agents_janitor import sweep_agents_dir from kiro_crew.autonudge import ( @@ -91,6 +92,7 @@ build_provider_factory, config_dir, data_home, + resolve_agent_bindings, ) from kiro_crew.config.paths import kiro_agents_dir from kiro_crew.constants import DATA_WARNING, SUBAGENT_COMPLETION_META_KEY, strip_control_comments @@ -147,10 +149,7 @@ parse_dashboard_url, resolve_dashboard_host, ) -from kiro_crew.dashboard.stale_asset_watchdog import ( - run_stale_asset_watchdog, - shutdown_exit_code, -) +from kiro_crew.dashboard.stale_asset_watchdog import run_stale_asset_watchdog, shutdown_exit_code from kiro_crew.dashboard.state import ( SUBAGENT_BATCH_COMPLETION_PREFIX, SUBAGENT_COMPLETION_PREFIX, @@ -207,16 +206,9 @@ ) from kiro_crew.mcp_cron import vet_job_at_fire_time from kiro_crew.mcp_gateway import is_gateway_supported -from kiro_crew.mcp_gateway.manager import ( - GatewayManager, - GatewaySpec, -) +from kiro_crew.mcp_gateway.manager import GatewayManager, GatewaySpec from kiro_crew.mcp_gateway.resolve_once import prefetch as resolve_prefetch -from kiro_crew.mcp_gateway.rewriter import ( - default_socket_path, - resolve_overlay_dir, - rewrite_agents, -) +from kiro_crew.mcp_gateway.rewriter import default_socket_path, resolve_overlay_dir, rewrite_agents from kiro_crew.mcp_hot_reload import parse_kiro_cli_version from kiro_crew.memory import MemoryStore from kiro_crew.messaging import APPROVAL_INTERACTIVE, TurnDriver, inbound_spool, registry @@ -298,12 +290,7 @@ ) from kiro_crew.sel import sel from kiro_crew.service.common import restart_command_hint -from kiro_crew.session import ( - HEARTBEAT_KEY, - SessionBusyError, - SessionClosingError, - SessionManager, -) +from kiro_crew.session import HEARTBEAT_KEY, SessionBusyError, SessionClosingError, SessionManager from kiro_crew.skills import SkillsLoader from kiro_crew.slack.client import RealSlackClient from kiro_crew.slack.format import ( @@ -803,6 +790,32 @@ def _build_heartbeat_hooks(user_hooks: HookManager) -> HookManager: return HookManager(scoped) +def _project_path_still_canonical(project_path: str) -> bool: + """Whether *project_path* is still the SAME path string after resolving symlinks. + + A bare ``os.path.isdir`` check is a TOCTOU: it passes as long as + something is a directory at this path string right now, even if the + original directory was deleted and a symlink to an unrelated checkout + now occupies the same path between when the job was saved and this fire. + ``project_path`` is already the ``realpath``-resolved value + ``CronService._validate_project_path`` stored at save time (see + ``cron.py``), so re-resolving it now and requiring an EXACT match against + the stored string catches exactly that: a symlink retargeted to point + somewhere else. It does NOT catch every swap — the original directory + deleted and a plain, non-symlinked directory recreated at the identical + literal path re-resolves to the same string and passes here, since + ``realpath`` has nothing to distinguish "same inode" from "same path, + different directory" once no symlink is involved. Detecting that case + would need an identity check (e.g. a stored inode/device pair), which + this function does not attempt. Synchronous filesystem I/O — callers on + the event loop MUST run this via ``asyncio.to_thread``, exactly like the + sibling checks in ``cron.py``. + """ + if not os.path.isdir(project_path): + return False + return os.path.realpath(project_path) == project_path + + class _GateTally: """Tool-gate outcomes accumulated over one cron run. @@ -1751,6 +1764,18 @@ def __init__( # Wave accounting for the completion digest (batch_id -> progress). self._batch_progress: dict[str, dict] = {} self._cron_injecting: dict[str, int] = {} # parent_key → pending injection count + # cron session key -> the (cwd, agent) it was last fired with. A live + # persistent session (job.persistent_session defaults True) is reused + # as-is by SessionManager.get_or_create regardless of the cwd/agent + # arguments passed to THIS call -- editing a job's project_path or + # agent binding between runs would otherwise silently run the stale + # provider under the old cwd and the old agent's permissions until an + # idle eviction or gateway restart cold-starts it. Checked at fire + # time in both cron paths below; a mismatch resets the session before + # acquiring it. In-memory only and deliberately not persisted: it is + # re-derived from the very next fire (the "safe" default when absent + # is "not yet known", never "known unchanged" — see the read site). + self._cron_session_binding: dict[str, tuple[str, str]] = {} self._running_script_ids: set[str] = ( set() ) # job IDs with in-flight script/command execution @@ -4985,10 +5010,34 @@ def _cron_extra_env() -> dict[str, str] | None: return env or None async def _acquire_with_model_fallback( - key: str, agent_id: str | None + key: str, + agent_id: str | None, + alias_model: str = "", + alias_crew_agent: str | None = None, ) -> "tuple[LLMProvider, bool, bool, bool]": """get_or_create honoring job.model; if that model is unavailable, retry once with the registry default. + + *alias_model* is the resolved Kiro Crew alias's own configured + model (``ResolvedBindings.model``) — used only when + ``job.model`` (the job-level pin, which outranks it) is unset. + Without this, substituting a project-scoped alias's + ``kiro_agent`` name here (done so the raw kiro-cli agent + actually runs, per resolve_agent_bindings) would silently drop + that alias's own model tier, since ``get_or_create`` sees only + the bare kiro_agent name and falls through to whatever THAT + agent defaults to instead. + + *alias_crew_agent* is ``ResolvedBindings.resolved_alias`` — + threaded through as ``crew_agent`` for the same reason as + *alias_model*: it is what ``crew_pinned_effort``/ + ``resolve_session_effort`` and the watchdog settings resolve + against (see ``dashboard/chat_runner.py``'s identical + ``crew_agent=crew_alias`` pattern). Substituting the bare + ``kiro_agent`` name into ``agent=`` without also passing this + would run a project-bound global alias under the wrong + crew-specific reasoning effort and watchdog settings. + Returns (client, is_new, resumed, downgraded).""" assert self.sessions is not None if cron_memory_store: @@ -5013,30 +5062,33 @@ async def _acquire_with_model_fallback( await prepare_store_vectors( self.ctx_builder, cron_memory_store, session_key=key ) + _model = job.model or alias_model or None try: client, is_new, resumed = await self.sessions.get_or_create( key, agent=agent_id, channel_id=job.channel, approval_policy=job.approval_mode, - model=job.model or None, + model=_model, + cwd=job.project_path or None, extra_env=_cron_extra_env(), + crew_agent=alias_crew_agent, ) return client, is_new, resumed, False except Exception as model_exc: - if not job.model: + if not _model: raise # Only fall back when the failure plausibly implicates the # pinned model; unrelated session-creation errors (provider # spawn, missing factory, transient I/O) must propagate so # they are not misreported as a model downgrade. _err = str(model_exc).lower() - if "model" not in _err and job.model.lower() not in _err: + if "model" not in _err and _model.lower() not in _err: raise logger.warning( "Cron '%s': model %r unavailable (%s); retrying with default", job.name, - job.model, + _model, model_exc, ) client, is_new, resumed = await self.sessions.get_or_create( @@ -5044,7 +5096,9 @@ async def _acquire_with_model_fallback( agent=agent_id, channel_id=job.channel, approval_policy=job.approval_mode, + cwd=job.project_path or None, extra_env=_cron_extra_env(), + crew_agent=alias_crew_agent, ) return client, is_new, resumed, True @@ -5061,20 +5115,132 @@ def _annotate_model_downgrade(text: str) -> str: if agent_sequence_dispatches(agents): assert self.sessions is not None assert self.ctx_builder is not None + # Same missing-folder skip as the single-agent path below, + # checked once here since job.project_path is constant across + # every member of the sequence — see that path's comment for + # the full rationale (a vanished path must not silently run + # ANY sequence member against the wrong, global-fallback + # agent). + if job.project_path: + # Recomputed fresh from disk on every fire, and the SOLE + # source of the missing-folder decision -- nothing + # persists it, matching the single-agent path below. + _path_missing = not await asyncio.to_thread( + _project_path_still_canonical, job.project_path + ) + if _path_missing: + logger.info( + "Cron '%s': operating folder %r no longer exists, skipping run", + job.name, + job.project_path, + ) + job.clear_carried_result() + job.last_status = "error" + job.last_error = f"Operating folder no longer exists: {job.project_path}" + job.run_never_started = True + return None result_text = "_No response._" _seq_downgraded = False # Run-scoped: a sequence where one agent got a tool through has # done work, even if a later agent was blocked outright. _gate = _GateTally() + # Pre-resolve EVERY sequence member's agent against + # job.project_path in one pass BEFORE any session is acquired + # or any turn runs — mirroring the missing-folder check above. + # Resolving inside the per-agent loop (the previous shape) let + # an early member run a REAL turn, then abort the whole run + # with run_never_started=True when a LATER member's agent + # could not be found — mislabeling a run that already did + # work as never having started at all, which defeats + # auto-pause failure accounting and would wrongly suppress a + # delete_after_run job's one-shot consume. Resolving everyone + # first means an unresolvable member is caught before + # anything executes, so the abort is always honest. + _resolved_seq_agents: dict[str, tuple[str, str, str | None]] = {} + if job.project_path: + await warm_project_agent_names( + job.project_path, operation="cron_fire", source="cron" + ) + _seq_cfg = await asyncio.to_thread(KiroCrewConfig.load) + for agent in agents: + # Offloaded: when `agent` names a real config.agents + # alias (not just a project-materialized agent), + # resolve_agent_bindings's alias_hit path calls + # require_member_memory_store(require_directory=True) + # -- real os.scandir/open/read/fstat syscalls on the + # member's memory-store directory, not the pure + # in-memory cache read _project_declares_agent uses. + # Same load as the sibling + # `_seq_cfg = await asyncio.to_thread(KiroCrewConfig.load)` + # two lines up, and chat_runner.py's identical + # "private memory validation ... access files, resolve + # off-loop" precedent. + _seq_bindings = await asyncio.to_thread( + resolve_agent_bindings, _seq_cfg, agent or None, job.project_path + ) + if not _seq_bindings.requested_resolved: + logger.info( + "Cron '%s': agent %r not found in %r, skipping run", + job.name, + agent, + job.project_path, + ) + job.clear_carried_result() + job.last_status = "error" + job.last_error = ( + f"Agent {agent!r} not found in operating folder " + f"{job.project_path!r}" + ) + job.run_never_started = True + if self.cron_svc is not None: + # No session key is minted this run -- the + # resolve failure is caught BEFORE any + # session is acquired (see the comment on the + # pre-resolve pass above). This clears any + # STALE key a prior run left registered, so + # the reaper does not target a session that + # maps to no live run. + _stale_key = self.cron_svc.get_active_session_key(job.id) + if _stale_key is not None: + self.cron_svc.clear_active_session_key(job.id, _stale_key) + return None + _resolved_seq_agents[agent] = ( + _seq_bindings.kiro_agent, + _seq_bindings.model, + _seq_bindings.resolved_alias, + ) for agent in agents: agent_session_key = f"cron:{job.id}:{agent}" if self.cron_svc is not None: self.cron_svc.register_active_session_key(job.id, agent_session_key) _acq = False try: + # Look up this member's pre-resolved agent + model + + # crew alias from the pass above — no-op (cheap) for + # the common case of no project_path, where the dict + # is empty and the raw name is used unchanged with no + # alias model, and crew identity left to + # resolve_crew_identity's namespace fallback (None, not + # "" — "" is the explicit no-crew opt-out). + _resolved_seq_agent, _seq_alias_model, _seq_crew_alias = ( + _resolved_seq_agents.get(agent, (agent, "", None)) + ) + # Same stale-session reset as the single-agent path -- + # see _cron_session_binding's own comment. + _seq_binding_now = (job.project_path or "", _resolved_seq_agent or "") + _seq_prior_binding = self._cron_session_binding.get(agent_session_key) + if ( + _seq_prior_binding is not None + and _seq_prior_binding != _seq_binding_now + ): + await self.sessions.reset(agent_session_key) client, is_new, _resumed, _downgraded = await _acquire_with_model_fallback( - agent_session_key, agent + agent_session_key, + _resolved_seq_agent, + _seq_alias_model, + _seq_crew_alias, ) + self._cron_session_binding[agent_session_key] = _seq_binding_now _seq_downgraded = _seq_downgraded or _downgraded _acq = True # Publish this turn's session identity so managed MCP @@ -5090,13 +5256,23 @@ def _annotate_model_downgrade(text: str) -> str: # unrelated surface happened to be mid-turn. await publish_turn_identity(self.sessions, agent_session_key) # Off-loop: build_message embeds the episodic query. + # Pass the pre-resolved kiro_agent name (same as + # _acquire_with_model_fallback above), not the raw + # sequence-member name: build_message's own agent + # lookup (_load_agent_prompt) matches against a + # config file's name/stem, and for a project-scoped + # member that is a Kiro Crew alias whose kiro_agent + # differs from the alias, the raw name would build the + # prompt for the wrong (or a nonexistent) agent while + # the actual session runs under the resolved one. full_message, _ = await run_in_embed_pool( self.ctx_builder.build_message, msg, True, interactive=False, - agent=agent, + agent=_resolved_seq_agent, memory_store=cron_memory_store or None, + project=job.project_path or None, ) # Wall clock for the cron agent turn: acp never assigns # TurnUsage.duration_ms, so the row falls back to this. @@ -5216,9 +5392,142 @@ def _annotate_model_downgrade(text: str) -> str: try: assert self.sessions is not None assert self.ctx_builder is not None + # cron_agent (resolve_cron_memory's second return value: plain + # job.agent_id, or -- when job.member_id is set -- that Crew + # Member's own kiro_agent, job.agent_id still winning if it + # names one) is the base identity; it is only meaningful once + # resolved against job.project_path — the same two-step + # discovery+resolve chat_runner.py uses for a dashboard slot's + # project agents (warm the on-demand index, then look the name + # up against it). A job with no project_path (the common case) + # takes the cheap path: warm_project_agent_names/ + # resolve_agent_bindings are both no-ops on an empty project + # dir, so this adds no cost to a global-agent job. + _resolved_agent_id = cron_agent or None + _alias_model = "" + # None (not "") so a non-project crew job keeps + # resolve_crew_identity's crew-namespace fallback: "" is the + # explicit "no crew" opt-out, which would suppress the crew + # effort/watchdog resolution a bare crew-name cron job relies + # on. Only the project_path branch below sets a concrete alias. + _alias_crew_agent: str | None = None + if job.project_path: + # Cheap existence check ahead of the discovery scan. A + # vanished path degrading silently to the global binding + # (warm_project_agent_names / resolve_agent_bindings both + # no-op on a missing dir) would run the WRONG agent with + # no visible sign beyond a list-page badge, which is not + # an acceptable failure mode for a project-scoped job: the + # whole point of setting project_path is that THIS run + # must use that project's agent, not silently fall back to + # a different one. Per explicit product decision, a + # missing path now SKIPS the run entirely rather than + # running degraded — same deliberately-neutral shape as + # the overlapping-run-refused path just above: + # last_status="error", run_never_started=True (so this + # spends no auto-pause budget; it's a precondition never + # met, not an agent execution failure), and no + # record_failure() call. + # + # Re-resolving (not just os.path.isdir) closes a TOCTOU: a + # bare isdir passes as long as SOMETHING is a directory at + # this path string, even if the real directory was deleted + # and a symlink to an unrelated checkout now sits at the + # same path between save time and this fire. job.project_path + # is itself already the realpath-resolved value _validate_ + # project_path stored at save time, so re-resolving and + # comparing catches exactly that swap. + _path_missing = not await asyncio.to_thread( + _project_path_still_canonical, job.project_path + ) + if _path_missing: + logger.info( + "Cron '%s': operating folder %r no longer exists, skipping run", + job.name, + job.project_path, + ) + job.clear_carried_result() + job.last_status = "error" + job.last_error = f"Operating folder no longer exists: {job.project_path}" + job.run_never_started = True + if self.cron_svc is not None: + # No session key was minted this run -- see the + # comment on the equivalent sequence-path exit + # above. + _stale_key = self.cron_svc.get_active_session_key(job.id) + if _stale_key is not None: + self.cron_svc.clear_active_session_key(job.id, _stale_key) + return None + await warm_project_agent_names( + job.project_path, operation="cron_fire", source="cron" + ) + _cfg_for_bindings = await asyncio.to_thread(KiroCrewConfig.load) + # Offloaded for the same reason as the sequential-loop + # equivalent above: cron_agent naming a real config.agents + # alias reaches require_member_memory_store's real + # filesystem syscalls via resolve_agent_bindings's + # alias_hit path, not just the pure-cache + # _project_declares_agent read -- matches the sibling + # `_cfg_for_bindings = await asyncio.to_thread(KiroCrewConfig.load)` + # a few lines up and chat_runner.py's identical pattern. + _bindings = await asyncio.to_thread( + resolve_agent_bindings, + _cfg_for_bindings, + cron_agent or None, + job.project_path, + ) + if not _bindings.requested_resolved: + # cron_agent (job.agent_id, or the Crew Member's own + # kiro_agent when job.member_id is set -- see + # resolve_cron_memory) named a specific agent inside + # job.project_path, but resolve_agent_bindings could + # not find it there (the agent's own JSON was removed, + # or never existed) -- so _bindings.kiro_agent is now + # the DEFAULT agent's binding, not the one this job + # asked for. Running anyway would + # execute the prompt under the default agent's tools and + # permissions with no indication anything was substituted. + # Same neutral shape as the missing-folder skip above: a + # skip is a normal failure, not a task defect, so no + # auto-pause strike is spent. + logger.info( + "Cron '%s': agent %r not found in %r, skipping run", + job.name, + cron_agent, + job.project_path, + ) + job.clear_carried_result() + job.last_status = "error" + job.last_error = ( + f"Agent {cron_agent!r} not found in operating folder " + f"{job.project_path!r}" + ) + job.run_never_started = True + if self.cron_svc is not None: + # No session key was minted this run -- see the + # comment on the sequence-path exit above. + _stale_key = self.cron_svc.get_active_session_key(job.id) + if _stale_key is not None: + self.cron_svc.clear_active_session_key(job.id, _stale_key) + return None + _resolved_agent_id = _bindings.kiro_agent + _alias_model = _bindings.model + _alias_crew_agent = _bindings.resolved_alias + # A live persistent session ignores the cwd/agent passed to + # get_or_create below and is reused exactly as it last was -- + # see _cron_session_binding's own comment. Reset it first + # when THIS fire's binding differs from the last one that + # actually acquired it, so an edited project_path/agent_id + # takes effect on the very next fire instead of only after + # an idle eviction or gateway restart. + _binding_now = (job.project_path or "", _resolved_agent_id or "") + _prior_binding = self._cron_session_binding.get(session_key) + if _prior_binding is not None and _prior_binding != _binding_now: + await self.sessions.reset(session_key) client, is_new, _resumed, _model_downgraded = await _acquire_with_model_fallback( - session_key, cron_agent or None + session_key, _resolved_agent_id, _alias_model, _alias_crew_agent ) + self._cron_session_binding[session_key] = _binding_now _acquired = True # Same identity publish as the sequential site above — the # single-agent cron turn must publish its pidfile mapping or @@ -5237,8 +5546,9 @@ def _annotate_model_downgrade(text: str) -> str: msg, True, interactive=False, - agent=job.agent_id or None, + agent=_resolved_agent_id, memory_store=cron_memory_store or None, + project=job.project_path or None, provider_type=_provider, minimal_context=job.minimal_context, ) @@ -12258,13 +12568,8 @@ async def _backfill_unclean_session_telemetry() -> None: # request. if not self._test_mode: with contextlib.suppress(Exception): - from kiro_crew.agent import ( - prime_ceiling_projection, - reproject_for_ceiling_change, - ) - from kiro_crew.dashboard.tailnet_serve import ( - revoke_if_governance_now_pins_off, - ) + from kiro_crew.agent import prime_ceiling_projection, reproject_for_ceiling_change + from kiro_crew.dashboard.tailnet_serve import revoke_if_governance_now_pins_off from kiro_crew.platform.policy_distribution import ( register_post_install_hook, start_refresher, diff --git a/test/test_agent_spec_hardened_reads.py b/test/test_agent_spec_hardened_reads.py index 192f215ac75..e15b47d315e 100644 --- a/test/test_agent_spec_hardened_reads.py +++ b/test/test_agent_spec_hardened_reads.py @@ -40,10 +40,7 @@ from kiro_crew.agent_files import AGENT_FILENAME, HEARTBEAT_AGENT_FILENAME from kiro_crew.connections import mint from kiro_crew.dashboard.chat_persistence import _build_kiro_model_map -from kiro_crew.dashboard.handlers.agents import ( - _namespaced_agent_file_exists, - api_agent_detail, -) +from kiro_crew.dashboard.handlers.agents import _namespaced_agent_file_exists, api_agent_detail from kiro_crew.dashboard.handlers.mcp import ( _collect_server_rows, _find_server_spec_anywhere, @@ -985,6 +982,7 @@ def test_bare_warm_emits_the_wrapper_defaults(self, tmp_path, monkeypatch): ], "kiro_crew/dashboard/chat_runner.py": [("chat_turn", "unknown")], "kiro_crew/dashboard/handlers/side.py": [("side_panel", "dashboard")], + "kiro_crew/slack/gateway.py": [("cron_fire", "cron"), ("cron_fire", "cron")], "kiro_crew/spawn_warm.py": [("spawn_warm", "unknown")], } diff --git a/test/test_agents_project_path_owner_gate.py b/test/test_agents_project_path_owner_gate.py new file mode 100644 index 00000000000..a7590599bd7 --- /dev/null +++ b/test/test_agents_project_path_owner_gate.py @@ -0,0 +1,97 @@ +"""Owner gate on the ``GET /api/agents?project_path=`` fallback. + +The raw ``project_path`` query-param fallback (added for the Schedule job +form, which has no live chat slot to key off of) has no owner check. +``is_sensitive_path`` guards only credential homes, not the multi-human +authorization boundary, so an allow-listed messaging user's non-owner +``!dashboard`` token (``app == ""``, which sails through every app-token +check) could name an arbitrary absolute path and read back that directory's +project agent names via ``_agent_roster_row`` -- a read no other caller's +project scope could ever cross into. These tests lock in that the fallback is +gated on the same ``is_owner_dashboard_request`` predicate this module's +mutating routes already use. +""" + +from __future__ import annotations + +import json as _json +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from chat_test_helpers import _make_state + +from kiro_crew.agent_discovery import clear_project_agent_cache +from kiro_crew.config.loader import KiroCrewAgentConfig + + +def _fake_config(): + return SimpleNamespace( + agents={"alpha": KiroCrewAgentConfig(kiro_agent="alpha")}, + default_agent="alpha", + ) + + +def _make_agents_app(state) -> web.Application: + from kiro_crew.dashboard.handlers.agents import api_kirocrew_agents + + app = web.Application() + app["state"] = state + app.router.add_get("/api/agents", api_kirocrew_agents) + return app + + +async def _get_agents_with_project_path(state, project_path: str, *, owner: bool): + with ( + patch( + "kiro_crew.dashboard.handlers.agents.KiroCrewConfig.load", + return_value=_fake_config(), + ), + patch( + "kiro_crew.dashboard.handlers.agents.requesting_slot_project", + lambda state, key: None, + ), + patch( + "kiro_crew.dashboard.handlers.source_providers.is_owner_dashboard_request", + lambda request: owner, + ), + ): + async with TestClient(TestServer(_make_agents_app(state))) as client: + resp = await client.get("/api/agents", params={"project_path": project_path}) + assert resp.status == 200 + data = await resp.json() + return data + + +class TestProjectPathFallbackOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_project_path_is_ignored(self, tmp_path): + proj = tmp_path / "repo" + (proj / ".kiro" / "agents").mkdir(parents=True) + (proj / ".kiro" / "agents" / "repo-bot.json").write_text(_json.dumps({"name": "repo-bot"})) + clear_project_agent_cache() + state = _make_state(tmp_path) + + data = await _get_agents_with_project_path(state, str(proj), owner=False) + + names = {a["name"] for a in data["agents"]} + assert "repo-bot" not in names, ( + "a non-owner request must never resolve project_path -- the " + "fallback must be silently ignored, not surfaced as an error " + "that would confirm the path's existence either way" + ) + + @pytest.mark.asyncio + async def test_owner_project_path_still_resolves(self, tmp_path): + proj = tmp_path / "repo" + (proj / ".kiro" / "agents").mkdir(parents=True) + (proj / ".kiro" / "agents" / "repo-bot.json").write_text(_json.dumps({"name": "repo-bot"})) + clear_project_agent_cache() + state = _make_state(tmp_path) + + data = await _get_agents_with_project_path(state, str(proj), owner=True) + + names = {a["name"] for a in data["agents"]} + assert "repo-bot" in names, "the owner's own request must still resolve project_path" diff --git a/test/test_agents_roster_contract.py b/test/test_agents_roster_contract.py index a6d51b70c95..1a2bedeaf70 100644 --- a/test/test_agents_roster_contract.py +++ b/test/test_agents_roster_contract.py @@ -158,8 +158,8 @@ async def test_project_row_ships_the_same_key_set(self, monkeypatch) -> None: answer for the whole response. """ monkeypatch.setattr( - "kiro_crew.dashboard.handlers.agents.active_project_dir", - lambda state, key: "/probe/project", + "kiro_crew.dashboard.handlers.agents.requesting_slot_project", + lambda state, key: Path("/probe/project"), ) monkeypatch.setattr( "kiro_crew.dashboard.handlers.agents.project_agent_names", diff --git a/test/test_api_agents_order.py b/test/test_api_agents_order.py index de9b2a415ea..732ae2345e1 100644 --- a/test/test_api_agents_order.py +++ b/test/test_api_agents_order.py @@ -133,9 +133,7 @@ class TestAgentOrderingFallback: async def test_history_unreadable_returns_config_order(self, tmp_path, monkeypatch): monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) state = _make_state(tmp_path) - with patch.object( - state.conversation_log, "agent_usage", side_effect=OSError("boom") - ): + with patch.object(state.conversation_log, "agent_usage", side_effect=OSError("boom")): data = await _get_agents(state, CONFIG_ORDER) order = [a["name"] for a in data["agents"]] @@ -175,8 +173,8 @@ async def test_project_agent_appears_with_project_scope(self, tmp_path, monkeypa (proj / ".kiro" / "agents" / "repo-bot.json").write_text(_json.dumps({"name": "repo-bot"})) clear_project_agent_cache() monkeypatch.setattr( - "kiro_crew.dashboard.handlers.agents.active_project_dir", - lambda state, key: str(proj), + "kiro_crew.dashboard.handlers.agents.requesting_slot_project", + lambda state, key: proj, ) state = _make_state(tmp_path) @@ -199,8 +197,8 @@ async def test_alias_shadows_project_agent_of_same_name(self, tmp_path, monkeypa (proj / ".kiro" / "agents" / "alpha.json").write_text(_json.dumps({"name": "alpha"})) clear_project_agent_cache() monkeypatch.setattr( - "kiro_crew.dashboard.handlers.agents.active_project_dir", - lambda state, key: str(proj), + "kiro_crew.dashboard.handlers.agents.requesting_slot_project", + lambda state, key: proj, ) state = _make_state(tmp_path) @@ -214,8 +212,8 @@ async def test_alias_shadows_project_agent_of_same_name(self, tmp_path, monkeypa async def test_no_project_dir_keeps_roster_global_only(self, tmp_path, monkeypatch): monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) monkeypatch.setattr( - "kiro_crew.dashboard.handlers.agents.active_project_dir", - lambda state, key: "", + "kiro_crew.dashboard.handlers.agents.requesting_slot_project", + lambda state, key: None, ) state = _make_state(tmp_path) diff --git a/test/test_cron.py b/test/test_cron.py index 77b76d8d10a..5549ceb2f0b 100644 --- a/test/test_cron.py +++ b/test/test_cron.py @@ -106,7 +106,9 @@ def test_add_job_enabled_false_registers_paused(self, tmp_path: Path) -> None: svc = CronService(base_dir=tmp_path) svc._load() job = svc.add_job( - name="shipped-disabled", message="", cron_expr="0 22 * * *", + name="shipped-disabled", + message="", + cron_expr="0 22 * * *", enabled=False, ) assert job.enabled is False @@ -118,7 +120,9 @@ def test_add_job_enabled_false_registers_paused(self, tmp_path: Path) -> None: assert loaded and loaded[0].enabled is False def test_add_job_enabled_false_never_persisted_enabled( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The paused state is part of the FIRST persist — no save may ever capture the disabled-by-manifest job in an enabled state (a crash or a @@ -137,13 +141,15 @@ def spy_save(*a, **k): monkeypatch.setattr(svc, "_save", spy_save) svc.add_job( - name="shipped-disabled", message="", cron_expr="0 22 * * *", + name="shipped-disabled", + message="", + cron_expr="0 22 * * *", enabled=False, ) assert snapshots, "add_job must persist the new job" - assert all(s == (False, True) for s in snapshots), ( - f"a save captured the job enabled: {snapshots}" - ) + assert all( + s == (False, True) for s in snapshots + ), f"a save captured the job enabled: {snapshots}" def test_add_job_invalid_cron_expr(self, tmp_path: Path) -> None: svc = CronService(base_dir=tmp_path) @@ -533,6 +539,124 @@ def test_update_job_model_clear(self, tmp_path: Path) -> None: assert updated.model == "" +class TestCronJobProjectPath: + """``project_path`` on a cron job: same validation bar as + ``chat_folders._validate_project_dir`` (absolute, resolved, non-sensitive, + existing directory), applied at the single locked ``_build_job``/ + ``_update_job_locked`` chokepoints so every create/update path (MCP, + dashboard REST, CLI) shares one check. + """ + + def test_add_job_default_project_path_empty(self, tmp_path: Path) -> None: + """The common case: no project_path set is unchanged behavior.""" + svc = CronService(base_dir=tmp_path) + svc._load() + job = svc.add_job(name="test", message="hello", every_secs=300) + assert job.project_path == "" + + def test_add_job_valid_project_path_persists(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + svc = CronService(base_dir=tmp_path / "cron_home") + svc._load() + job = svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path=str(project_dir), + ) + assert job.project_path == str(project_dir.resolve()) + # Round-trips through a fresh load of the store. + svc2 = CronService(base_dir=tmp_path / "cron_home") + svc2._load() + loaded = [j for j in svc2.list_jobs(include_disabled=True) if j.id == job.id] + assert loaded and loaded[0].project_path == str(project_dir.resolve()) + + def test_add_job_nonexistent_project_path_rejected(self, tmp_path: Path) -> None: + svc = CronService(base_dir=tmp_path) + svc._load() + missing = tmp_path / "does-not-exist" + with pytest.raises(ValueError, match="existing directory"): + svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path=str(missing), + ) + # A rejected create must not leave an orphaned job on disk. + assert svc.list_jobs(include_disabled=True) == [] + + def test_add_job_relative_project_path_rejected(self, tmp_path: Path) -> None: + svc = CronService(base_dir=tmp_path) + svc._load() + with pytest.raises(ValueError, match="absolute path"): + svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path="relative/path", + ) + + def test_add_job_sensitive_project_path_rejected(self, tmp_path: Path) -> None: + svc = CronService(base_dir=tmp_path) + svc._load() + with pytest.raises(ValueError, match="sensitive path"): + svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path=str(Path.home() / ".ssh"), + ) + + def test_update_job_sets_project_path(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + svc = CronService(base_dir=tmp_path / "cron_home") + svc._load() + job = svc.add_job(name="test", message="hello", every_secs=300) + assert job.project_path == "" + updated = svc.update_job(job.id, project_path=str(project_dir)) + assert updated is not None + assert updated.project_path == str(project_dir.resolve()) + + def test_update_job_clears_project_path(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + svc = CronService(base_dir=tmp_path / "cron_home") + svc._load() + job = svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path=str(project_dir), + ) + updated = svc.update_job(job.id, project_path="") + assert updated is not None + assert updated.project_path == "" + + def test_update_job_invalid_project_path_rejected_leaves_existing_unchanged( + self, + tmp_path: Path, + ) -> None: + """A rejected update must not strand earlier field mutations, and must + not clobber the job's existing (valid) project_path either.""" + project_dir = tmp_path / "myproject" + project_dir.mkdir() + svc = CronService(base_dir=tmp_path / "cron_home") + svc._load() + job = svc.add_job( + name="test", + message="hello", + every_secs=300, + project_path=str(project_dir), + ) + with pytest.raises(ValueError, match="existing directory"): + svc.update_job(job.id, project_path=str(tmp_path / "nope")) + reloaded = svc.get_job(job.id) + assert reloaded is not None + assert reloaded.project_path == str(project_dir.resolve()) + + class TestLastResultTimestamp: """``last_result_ts`` identifies WHICH run produced ``last_result``. @@ -599,13 +723,15 @@ def test_stamp_is_rendered_in_the_job_timezone_to_the_second(self) -> None: the row content the dedup compares, so anything coarser merges two runs that finished within the same interval. """ - job = CronJob(id="tz1", name="tz", message="go", schedule=CronSchedule(kind="every", every_secs=300)) + job = CronJob( + id="tz1", name="tz", message="go", schedule=CronSchedule(kind="every", every_secs=300) + ) job.timezone = "UTC" job.set_run_result("output") assert job.last_result_stamp.startswith(" | ") # ' | YYYY-MM-DD HH:MM:SS UTC' assert job.last_result_stamp.endswith("UTC") - stamped = job.last_result_stamp[len(" | "): -len(" UTC")] + stamped = job.last_result_stamp[len(" | ") : -len(" UTC")] datetime.strptime(stamped, "%Y-%m-%d %H:%M:%S") def test_an_unknown_timezone_still_renders_via_the_utc_fallback(self) -> None: @@ -616,7 +742,9 @@ def test_an_unknown_timezone_still_renders_via_the_utc_fallback(self) -> None: rows dedup against: a run must not lose its stamp over a config typo. """ job = CronJob( - id="tz2", name="tz", message="go", + id="tz2", + name="tz", + message="go", schedule=CronSchedule(kind="every", every_secs=300), ) job.timezone = "Not/AZone" @@ -632,7 +760,9 @@ def test_an_unrenderable_epoch_degrades_to_no_stamp(self) -> None: instead of gaining a third variant of the same row. """ job = CronJob( - id="tz3", name="tz", message="go", + id="tz3", + name="tz", + message="go", schedule=CronSchedule(kind="every", every_secs=300), ) # Beyond what the platform can turn into a date, which is what the @@ -829,7 +959,9 @@ class TestJobCompletionRearmsTimer: async def test_run_job_isolated_rearms_the_timer(self, tmp_path: Path) -> None: svc = CronService(base_dir=tmp_path) job = CronJob( - id="j1", name="watch", message="go", + id="j1", + name="watch", + message="go", schedule=CronSchedule(kind="every", every_secs=60), ) svc._jobs = [job] @@ -845,14 +977,14 @@ async def test_run_job_isolated_rearms_the_timer(self, tmp_path: Path) -> None: mock_arm.assert_called_once() @pytest.mark.asyncio - async def test_run_job_isolated_does_not_rearm_a_stopped_service( - self, tmp_path: Path - ) -> None: + async def test_run_job_isolated_does_not_rearm_a_stopped_service(self, tmp_path: Path) -> None: """A job finishing during/after shutdown must not spin up a fresh timer task behind close_all()'s back.""" svc = CronService(base_dir=tmp_path) job = CronJob( - id="j1", name="watch", message="go", + id="j1", + name="watch", + message="go", schedule=CronSchedule(kind="every", every_secs=60), ) svc._jobs = [job] @@ -879,7 +1011,9 @@ async def test_completed_job_replaces_a_longer_sleeping_timer_task( shorter one instead of leaving the stale one in place.""" svc = CronService(base_dir=tmp_path) job = CronJob( - id="j1", name="watch", message="go", + id="j1", + name="watch", + message="go", schedule=CronSchedule(kind="every", every_secs=60), ) svc._jobs = [job] @@ -915,9 +1049,7 @@ class TestArmTimerDuringOnTimer: doesn't cover it. See _arm_timer's second guard clause.""" @pytest.mark.asyncio - async def test_arm_timer_does_not_cancel_the_timer_task_mid_sweep( - self, tmp_path: Path - ) -> None: + async def test_arm_timer_does_not_cancel_the_timer_task_mid_sweep(self, tmp_path: Path) -> None: svc = CronService(base_dir=tmp_path) svc._running = True svc._loop = asyncio.get_running_loop() @@ -1062,10 +1194,19 @@ def test_at_timestamp_today(self, monkeypatch, _utc_tz) -> None: # Mock "now" to Apr 10, job at 3PM same day fake_now = datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc) # Mock only covers now() and fromtimestamp() — extend if format_schedule evolves. - monkeypatch.setattr("kiro_crew.cron.datetime", type("D", (datetime,), { - "now": classmethod(lambda cls, tz=None: fake_now), - "fromtimestamp": staticmethod(lambda ts, tz=None: datetime.fromtimestamp(ts, tz)), - })) + monkeypatch.setattr( + "kiro_crew.cron.datetime", + type( + "D", + (datetime,), + { + "now": classmethod(lambda cls, tz=None: fake_now), + "fromtimestamp": staticmethod( + lambda ts, tz=None: datetime.fromtimestamp(ts, tz) + ), + }, + ), + ) job_ts = datetime(2026, 4, 10, 15, 0, tzinfo=timezone.utc).timestamp() result = format_schedule(CronSchedule(kind="at", at_ts=job_ts)) assert result.startswith("at ") @@ -1077,10 +1218,19 @@ def test_at_timestamp_future_date(self, monkeypatch, _utc_tz) -> None: # Mock "now" to Apr 10, job on Apr 17 fake_now = datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc) # Mock only covers now() and fromtimestamp() — extend if format_schedule evolves. - monkeypatch.setattr("kiro_crew.cron.datetime", type("D", (datetime,), { - "now": classmethod(lambda cls, tz=None: fake_now), - "fromtimestamp": staticmethod(lambda ts, tz=None: datetime.fromtimestamp(ts, tz)), - })) + monkeypatch.setattr( + "kiro_crew.cron.datetime", + type( + "D", + (datetime,), + { + "now": classmethod(lambda cls, tz=None: fake_now), + "fromtimestamp": staticmethod( + lambda ts, tz=None: datetime.fromtimestamp(ts, tz) + ), + }, + ), + ) job_ts = datetime(2026, 4, 17, 8, 0, tzinfo=timezone.utc).timestamp() result = format_schedule(CronSchedule(kind="at", at_ts=job_ts)) assert "Apr 17" in result @@ -1130,9 +1280,7 @@ def _record_load(): return type("C", (), {"timezone": "Bad/Zone"})() monkeypatch.setattr("kiro_crew.cron.KiroCrewConfig.load", staticmethod(_record_load)) - monkeypatch.setattr( - "kiro_crew.cron.published_config_timezone", lambda: "America/New_York" - ) + monkeypatch.setattr("kiro_crew.cron.published_config_timezone", lambda: "America/New_York") s = CronSchedule(kind="cron", cron_expr="0 22 * * 1-5") result = format_schedule(s) # Expression is evaluated in job timezone (ET fallback), so 22:00 = 10 PM local @@ -1301,16 +1449,14 @@ def _record_load(): return type("C", (), {"timezone": "Bad/Zone"})() monkeypatch.setattr("kiro_crew.cron.KiroCrewConfig.load", staticmethod(_record_load)) - monkeypatch.setattr( - "kiro_crew.cron.published_config_timezone", lambda: "America/Toronto" - ) + monkeypatch.setattr("kiro_crew.cron.published_config_timezone", lambda: "America/Toronto") assert _job_tz(CronJob(id="j1", name="t", message="m", timezone="")) == ZoneInfo( "America/Toronto" ) - assert _job_tz( - CronJob(id="j2", name="t", message="m", timezone="Asia/Tokyo") - ) == ZoneInfo("Asia/Tokyo") + assert _job_tz(CronJob(id="j2", name="t", message="m", timezone="Asia/Tokyo")) == ZoneInfo( + "Asia/Tokyo" + ) assert not loads, "_job_tz loaded config.json on the event loop" def test_get_local_tz_never_loads_the_config_file(self, monkeypatch) -> None: @@ -1477,9 +1623,7 @@ def test_is_due_normal_day_fires_exactly_once(self) -> None: ) window_start = datetime(2025, 3, 10, 6, 0, tzinfo=timezone.utc) fires = [ - i - for i in range(180) - if CronService._is_due(job, window_start.timestamp() + i * 60) + i for i in range(180) if CronService._is_due(job, window_start.timestamp() + i * 60) ] assert len(fires) == 1 diff --git a/test/test_cron_acp_retry.py b/test/test_cron_acp_retry.py index ed65e421150..d73ed52cfbf 100644 --- a/test/test_cron_acp_retry.py +++ b/test/test_cron_acp_retry.py @@ -36,6 +36,7 @@ def gw_and_cb() -> tuple[Any, Callable[[], Any], Callable[..., Any]]: gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw._interactive_approval = MagicMock(return_value="interactive_cb") @@ -76,7 +77,10 @@ async def mock_stream(*args: Any, **kwargs: Any) -> str: patch("kiro_crew.slack.gateway.stream_and_collect", side_effect=mock_stream), patch("kiro_crew.slack.gateway.redact_exfiltration_urls", return_value=("", False)), patch("kiro_crew.slack.gateway.redact_credentials", return_value=("", False)), - patch("kiro_crew.slack.gateway.CronService.create", new=AsyncMock(side_effect=capture_cron)), + patch( + "kiro_crew.slack.gateway.CronService.create", + new=AsyncMock(side_effect=capture_cron), + ), ): async def _init_and_run() -> str: @@ -114,7 +118,10 @@ async def mock_stream(*args: Any, **kwargs: Any) -> str: patch("kiro_crew.slack.gateway.stream_and_collect", side_effect=mock_stream), patch("kiro_crew.slack.gateway.redact_exfiltration_urls", return_value=("", False)), patch("kiro_crew.slack.gateway.redact_credentials", return_value=("", False)), - patch("kiro_crew.slack.gateway.CronService.create", new=AsyncMock(side_effect=capture_cron)), + patch( + "kiro_crew.slack.gateway.CronService.create", + new=AsyncMock(side_effect=capture_cron), + ), ): async def _init_and_run() -> str: @@ -151,7 +158,10 @@ async def mock_stream(*args: Any, **kwargs: Any) -> str: patch("kiro_crew.slack.gateway.stream_and_collect", side_effect=mock_stream), patch("kiro_crew.slack.gateway.redact_exfiltration_urls", return_value=("", False)), patch("kiro_crew.slack.gateway.redact_credentials", return_value=("", False)), - patch("kiro_crew.slack.gateway.CronService.create", new=AsyncMock(side_effect=capture_cron)), + patch( + "kiro_crew.slack.gateway.CronService.create", + new=AsyncMock(side_effect=capture_cron), + ), ): async def _init_and_run() -> str: @@ -192,7 +202,10 @@ async def mock_stream(*args: Any, **kwargs: Any) -> str: patch("kiro_crew.slack.gateway.stream_and_collect", side_effect=mock_stream), patch("kiro_crew.slack.gateway.redact_exfiltration_urls", return_value=("", False)), patch("kiro_crew.slack.gateway.redact_credentials", return_value=("", False)), - patch("kiro_crew.slack.gateway.CronService.create", new=AsyncMock(side_effect=capture_cron)), + patch( + "kiro_crew.slack.gateway.CronService.create", + new=AsyncMock(side_effect=capture_cron), + ), ): async def _init_and_run() -> str: diff --git a/test/test_cron_approval_mode.py b/test/test_cron_approval_mode.py index fffd088efc6..b4b6cf3eb7c 100644 --- a/test/test_cron_approval_mode.py +++ b/test/test_cron_approval_mode.py @@ -59,6 +59,7 @@ def _run_cron_callback(self, approval_mode: str) -> dict: gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw.sessions.get_or_create = AsyncMock(return_value=(MagicMock(), True, False)) gw.sessions.release = MagicMock() @@ -84,9 +85,10 @@ async def fake_stream(client, msg, **kwargs): captured_cb = None - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb @@ -137,6 +139,7 @@ def _run_cron_callback_capture_get_or_create( gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False captured_kwargs: dict = {} @@ -166,9 +169,10 @@ async def fake_get_or_create(key, **kwargs): captured_cb = None - with patch("kiro_crew.slack.gateway.stream_and_collect", AsyncMock(return_value="done")), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", AsyncMock(return_value="done")), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb @@ -316,7 +320,9 @@ def test_no_parent_session_defaults_empty(self) -> None: class TestSubagentInheritsPolicy: """Subagent _run_inner passes parent's approval_policy to get_or_create.""" - def _run_inner_and_capture(self, parent_policy: str, parent_session_key: str = "parent-key") -> dict: + def _run_inner_and_capture( + self, parent_policy: str, parent_session_key: str = "parent-key" + ) -> dict: """Invoke the real _run_inner and capture get_or_create kwargs.""" from kiro_crew.providers.base import EVENT_COMPLETE, LLMEvent from kiro_crew.subagent import SubagentInfo, SubagentManager @@ -366,7 +372,9 @@ def test_empty_policy_flows_to_child_session(self) -> None: captured = self._run_inner_and_capture("", parent_session_key="") assert captured["approval_policy"] == "auto" - def _run_inner_with_tool_event(self, parent_policy: str, on_tool_approval=None, parent_session_key: str = "parent-key") -> MagicMock: + def _run_inner_with_tool_event( + self, parent_policy: str, on_tool_approval=None, parent_session_key: str = "parent-key" + ) -> MagicMock: """Invoke _run_inner with a PERMISSION_REQUEST event and return the mock client.""" from kiro_crew.hooks import TOOL_ALLOW, ToolHookResult from kiro_crew.providers.base import EVENT_COMPLETE, EVENT_PERMISSION_REQUEST, LLMEvent @@ -421,7 +429,9 @@ def test_deny_by_default_rejects_tool(self) -> None: "kiro_crew.subagent.KiroCrewConfig.load", return_value=MagicMock(agent=MagicMock(approval_mode="interactive")), ): - client = self._run_inner_with_tool_event("", on_tool_approval=None, parent_session_key="") + client = self._run_inner_with_tool_event( + "", on_tool_approval=None, parent_session_key="" + ) client.reject_tool.assert_called_once_with("req-1") client.approve_tool.assert_not_called() @@ -463,6 +473,7 @@ def _build_gw(self): # type: ignore[no-untyped-def] gw.dashboard_state = None gw._owner_id = "U000" gw._cron_injecting = {} + gw._cron_session_binding = {} gw._cfg = MagicMock() gw._cfg.agent.max_subagents = 5 gw.sessions.get_or_create = AsyncMock(return_value=(MagicMock(), True, False)) @@ -582,10 +593,14 @@ def test_cron_injection_failure_still_cleans_up(self) -> None: return_value=("", False), ), ) - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - AsyncMock(side_effect=RuntimeError("boom")), - ), p2, p3: + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + AsyncMock(side_effect=RuntimeError("boom")), + ), + p2, + p3, + ): asyncio.run(done_cb(info)) assert gw._cron_injecting == {} @@ -688,10 +703,13 @@ def test_no_crons_skips_start(self) -> None: def test_default_starts_crons_after_app_reconcile(self) -> None: gw = self._make_gateway(no_crons=False) - with patch("kiro_crew.slack.gateway.CronService") as mock_cls, patch( - "kiro_crew.apps.bridges.reconcile_app_crons_for_execution", - new_callable=AsyncMock, - ) as reconcile: + with ( + patch("kiro_crew.slack.gateway.CronService") as mock_cls, + patch( + "kiro_crew.apps.bridges.reconcile_app_crons_for_execution", + new_callable=AsyncMock, + ) as reconcile, + ): svc = MagicMock() svc.start = AsyncMock() mock_cls.return_value = svc @@ -702,10 +720,13 @@ def test_default_starts_crons_after_app_reconcile(self) -> None: def test_reconcile_failure_leaves_cron_scheduler_stopped(self) -> None: gw = self._make_gateway(no_crons=False) - with patch("kiro_crew.slack.gateway.CronService") as mock_cls, patch( - "kiro_crew.apps.bridges.reconcile_app_crons_for_execution", - new_callable=AsyncMock, - side_effect=OSError("cron store unavailable"), + with ( + patch("kiro_crew.slack.gateway.CronService") as mock_cls, + patch( + "kiro_crew.apps.bridges.reconcile_app_crons_for_execution", + new_callable=AsyncMock, + side_effect=OSError("cron store unavailable"), + ), ): svc = MagicMock() svc.start = AsyncMock() @@ -770,11 +791,11 @@ def test_cli_gateway_passes_no_crons(self) -> None: """CLI _gateway function forwards no_crons to run_gateway.""" from kiro_crew.cli_server import _gateway - with patch("kiro_crew.cli_server.config_path") as mock_cp, patch( - "kiro_crew.cli_server.KiroCrewConfig" - ) as mock_cfg_cls, patch( - "kiro_crew.cli_server.run_gateway", new_callable=AsyncMock - ) as mock_run: + with ( + patch("kiro_crew.cli_server.config_path") as mock_cp, + patch("kiro_crew.cli_server.KiroCrewConfig") as mock_cfg_cls, + patch("kiro_crew.cli_server.run_gateway", new_callable=AsyncMock) as mock_run, + ): mock_cp.return_value.exists.return_value = True mock_cfg_cls.load.return_value = MagicMock() asyncio.run(_gateway(no_crons=True)) @@ -788,9 +809,10 @@ def test_cli_argparse_no_crons_flag(self) -> None: with patch.object(sys, "argv", ["kirocrew", "gateway", "--no-crons"]): from kiro_crew.cli import main - with patch("kiro_crew.cli_server._gateway", new_callable=AsyncMock) as mock_gw, patch( - "kiro_crew.cli.asyncio" - ) as mock_asyncio: + with ( + patch("kiro_crew.cli_server._gateway", new_callable=AsyncMock) as mock_gw, + patch("kiro_crew.cli.asyncio") as mock_asyncio, + ): mock_asyncio.run = MagicMock() main() mock_gw.assert_called_once() @@ -838,9 +860,11 @@ async def fake_stream(msg): side_effect=AssertionError("shared path taken despite a per-role override") ) info = SubagentInfo(id="sub1", task="test", parent_session_key="parent-key") - with patch.object(runner, "_create_shared_session", shared), patch.object( - runner, "_should_use_session_sharing", return_value=True - ), patch("kiro_crew.config.loader.KiroCrewConfig.load", classmethod(lambda c: cfg)): + with ( + patch.object(runner, "_create_shared_session", shared), + patch.object(runner, "_should_use_session_sharing", return_value=True), + patch("kiro_crew.config.loader.KiroCrewConfig.load", classmethod(lambda c: cfg)), + ): asyncio.run(runner._run_inner(info, "subagent:sub1")) return captured, shared diff --git a/test/test_cron_dedup.py b/test/test_cron_dedup.py index 49685de360f..de819fa9f8f 100644 --- a/test/test_cron_dedup.py +++ b/test/test_cron_dedup.py @@ -32,6 +32,7 @@ def _make_gateway(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw.sessions.get_or_create = AsyncMock(return_value=(MagicMock(), True, False)) gw.sessions.release = MagicMock() @@ -64,9 +65,10 @@ def _run_callback(gw, job, stream_result="done"): async def fake_stream(client, msg, **kwargs): return stream_result - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb @@ -268,9 +270,11 @@ def _run_callback_raising(gw, job, exc): async def fake_stream(client, msg, **kwargs): raise exc - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls, patch("kiro_crew.sel.sel"): + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + patch("kiro_crew.sel.sel"), + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb @@ -325,9 +329,7 @@ def test_duplicate_failure_suppressed(self) -> None: assert gw.slack.post_message.await_count == 1 # no new Slack post assert job.consecutive_failures == 2 # Dashboard still gets notified (with dup marker) - dup_calls = [ - c for c in gw.dashboard_state.notify.call_args_list if "dup failure" in str(c) - ] + dup_calls = [c for c in gw.dashboard_state.notify.call_args_list if "dup failure" in str(c)] assert dup_calls, "Expected a dup failure dashboard notification" def test_different_failure_re_alerts(self) -> None: @@ -516,8 +518,9 @@ async def _hang(*args, **kwargs): timeout_secs=0, ) # Pretend _execute hangs so _execute_with_timeout triggers the timeout. - with patch.object(svc, "_execute", side_effect=_hang), patch( - "kiro_crew.cron._JOB_TIMEOUT_SECS", 0.05 + with ( + patch.object(svc, "_execute", side_effect=_hang), + patch("kiro_crew.cron._JOB_TIMEOUT_SECS", 0.05), ): asyncio.run(svc._execute_with_timeout(job)) assert job.last_status == "error" @@ -551,8 +554,9 @@ async def _hang(*args, **kwargs): ) svc._jobs = [job] svc._save() - with patch.object(svc, "_execute", side_effect=_hang), patch( - "kiro_crew.cron._JOB_TIMEOUT_SECS", 0.05 + with ( + patch.object(svc, "_execute", side_effect=_hang), + patch("kiro_crew.cron._JOB_TIMEOUT_SECS", 0.05), ): asyncio.run(svc._run_job_isolated(job)) svc2 = CronService(base_dir=tmp_path) @@ -657,8 +661,7 @@ def test_non_silent_first_failure_still_rings_dashboard(self) -> None: _run_callback_raising(gw, job, RuntimeError("boom")) gw.slack.post_message.assert_awaited_once() alert_calls = [ - c for c in gw.dashboard_state.notify.call_args_list - if "❌ Job failed" in str(c) + c for c in gw.dashboard_state.notify.call_args_list if "❌ Job failed" in str(c) ] assert alert_calls, "Non-silent cron failure must still ring the dashboard bell" diff --git a/test/test_cron_gateway_integration.py b/test/test_cron_gateway_integration.py index 074d665ad32..ce7b16e9019 100644 --- a/test/test_cron_gateway_integration.py +++ b/test/test_cron_gateway_integration.py @@ -43,6 +43,7 @@ def _make_gw(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._running_script_ids = set() gw._no_crons = False gw.cron_svc = MagicMock() @@ -993,6 +994,7 @@ def _make_gw_for_llm(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._running_script_ids = set() gw._no_crons = False gw.cron_svc = MagicMock() @@ -1044,6 +1046,7 @@ def capture_cron(on_job=None, **kw): captured_cb = on_job svc = MagicMock() svc.start = AsyncMock() + svc.update_job_async = AsyncMock() svc.remove_job_async = AsyncMock(return_value=True) return svc @@ -1174,6 +1177,233 @@ async def _side_effect(*args, **kwargs): await _run_llm_callback(gw, job, get_or_create_side_effect=_side_effect) +class TestProjectPathMissingSkipsRun: + """A job's operating folder existing at save time but gone by fire time + must SKIP the run entirely (no agent invoked, no session acquired) and + record it as a normal failed run — not silently fall back to a global + agent, which ran the wrong agent with no visible sign beyond a list-page + badge (the original, since-reverted behavior). + """ + + @pytest.mark.asyncio + async def test_single_agent_job_skips_and_marks_error(self, tmp_path): + gw = _make_gw_for_llm() + gw.cron_svc.update_job_async = AsyncMock() + vanished = str(tmp_path / "does-not-exist") + job = _make_llm_job(project_path=vanished, agent_id="ea-dev") + + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result is None + assert job.last_status == "error" + assert vanished in job.last_error + assert job.run_never_started is True + # No agent turn ran: neither the session acquire nor the prompt + # stream was ever reached. + gw.sessions.get_or_create.assert_not_called() + _stream_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_existing_folder_runs_normally(self, tmp_path): + # Control: a REAL directory must not trip the skip at all — the + # normal single-agent turn still runs and returns its result. + gw = _make_gw_for_llm() + job = _make_llm_job(project_path=str(tmp_path), agent_id="ea-dev") + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.kiro_agent = "ea-dev" + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result == "Agent response here" + assert job.last_status != "error" + assert job.run_never_started is False + gw.sessions.get_or_create.assert_called() + + @pytest.mark.asyncio + async def test_sequential_job_skips_before_any_sequence_member_runs(self, tmp_path): + gw = _make_gw_for_llm() + gw.cron_svc.update_job_async = AsyncMock() + vanished = str(tmp_path / "does-not-exist") + job = _make_llm_job(project_path=vanished, agent_sequence=["agent-a", "agent-b"]) + + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result is None + assert job.last_status == "error" + assert vanished in job.last_error + assert job.run_never_started is True + gw.sessions.get_or_create.assert_not_called() + _stream_mock.assert_not_called() + + +class TestUnresolvedProjectAgentSkipsRun: + """A job whose ``agent_id``/sequence member names a specific agent that + ``resolve_agent_bindings`` cannot find inside ``project_path`` (the + folder exists, but the agent's own JSON does not, or never did) must + SKIP the run — not silently execute under the default agent's tools and + permissions with no visible sign anything was substituted. Distinct from + ``TestProjectPathMissingSkipsRun``, which covers the folder itself being + gone; this covers the folder existing but not declaring the requested + agent. + """ + + @pytest.mark.asyncio + async def test_single_agent_job_skips_and_marks_error(self, tmp_path): + gw = _make_gw_for_llm() + gw.cron_svc.update_job_async = AsyncMock() + job = _make_llm_job(project_path=str(tmp_path), agent_id="ghost-agent") + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.requested_resolved = False + mock_resolve.return_value.kiro_agent = "default-agent" + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result is None + assert job.last_status == "error" + assert "ghost-agent" in job.last_error + assert job.run_never_started is True + # No agent turn ran: neither the session acquire nor the prompt + # stream was ever reached — the resolved default binding must never + # be handed to a session. + gw.sessions.get_or_create.assert_not_called() + _stream_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_sequential_job_skips_before_any_sequence_member_runs(self, tmp_path): + gw = _make_gw_for_llm() + gw.cron_svc.update_job_async = AsyncMock() + job = _make_llm_job(project_path=str(tmp_path), agent_sequence=["ghost-a", "ghost-b"]) + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.requested_resolved = False + mock_resolve.return_value.kiro_agent = "default-agent" + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result is None + assert job.last_status == "error" + assert "ghost-a" in job.last_error + assert job.run_never_started is True + gw.sessions.get_or_create.assert_not_called() + _stream_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_resolved_project_agent_still_runs_normally(self, tmp_path): + # Control: requested_resolved=True (the agent WAS found in the + # project) must not trip the skip — the normal single-agent turn + # still runs and returns its result. + gw = _make_gw_for_llm() + job = _make_llm_job(project_path=str(tmp_path), agent_id="ea-dev") + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.requested_resolved = True + mock_resolve.return_value.kiro_agent = "ea-dev" + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result == "Agent response here" + assert job.last_status != "error" + assert job.run_never_started is False + gw.sessions.get_or_create.assert_called() + + @pytest.mark.asyncio + async def test_sequential_run_never_starts_if_a_later_member_is_unresolvable(self, tmp_path): + # Resolving every sequence member in one pre-pass, before any session + # is acquired, means an unresolvable later member is caught before the + # FIRST member ever runs -- resolving inside the per-agent execution + # loop instead would let an early member run a REAL turn before a + # later member's unresolvable agent aborts the whole run and marks it + # run_never_started=True, mislabeling a run that already executed. + # The pre-pass keeps run_never_started honest. + gw = _make_gw_for_llm() + gw.cron_svc.update_job_async = AsyncMock() + job = _make_llm_job( + project_path=str(tmp_path), agent_sequence=["resolves-fine", "ghost-agent"] + ) + + def _resolve_side_effect(_cfg, agent, _project_path): + b = MagicMock() + b.requested_resolved = agent != "ghost-agent" + b.kiro_agent = agent + b.model = "" + return b + + with ( + patch( + "kiro_crew.slack.gateway.resolve_agent_bindings", + side_effect=_resolve_side_effect, + ), + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + result, _stream_mock = await _run_llm_callback(gw, job) + + assert result is None + assert job.last_status == "error" + assert "ghost-agent" in job.last_error + assert job.run_never_started is True + # The whole point of the fix: NO session was ever acquired, even for + # the first (resolvable) member -- proving the abort happened in the + # pre-pass, before any turn ran. + gw.sessions.get_or_create.assert_not_called() + _stream_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_resolved_alias_model_is_not_dropped(self, tmp_path): + # Substituting job.agent_id with _bindings.kiro_agent (the raw + # kiro-cli agent name) so the resolved agent actually dispatches must + # not silently drop that alias's OWN configured model tier + # (ResolvedBindings.model) -- get_or_create must not see only the bare + # kiro_agent name and fall through to whatever THAT agent defaults to. + # job.model (a job-level pin) is empty here, so the alias's model must + # be the one that reaches get_or_create. + gw = _make_gw_for_llm() + job = _make_llm_job(project_path=str(tmp_path), agent_id="ea-dev", model="") + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.requested_resolved = True + mock_resolve.return_value.kiro_agent = "raw-kiro-agent-name" + mock_resolve.return_value.model = "alias-pinned-model" + await _run_llm_callback(gw, job) + + gw.sessions.get_or_create.assert_called() + _, kwargs = gw.sessions.get_or_create.call_args + assert kwargs.get("model") == "alias-pinned-model" + assert kwargs.get("agent") == "raw-kiro-agent-name" + + @pytest.mark.asyncio + async def test_job_level_model_pin_still_outranks_the_alias_model(self, tmp_path): + # A job-level job.model pin outranks the resolved alias's own model -- + # confirming the fix's precedence (job.model or alias_model) rather + # than accidentally swapping which one wins. + gw = _make_gw_for_llm() + job = _make_llm_job(project_path=str(tmp_path), agent_id="ea-dev", model="job-pinned-model") + + with ( + patch("kiro_crew.slack.gateway.resolve_agent_bindings") as mock_resolve, + patch("kiro_crew.slack.gateway.warm_project_agent_names", AsyncMock()), + ): + mock_resolve.return_value.requested_resolved = True + mock_resolve.return_value.kiro_agent = "raw-kiro-agent-name" + mock_resolve.return_value.model = "alias-pinned-model" + await _run_llm_callback(gw, job) + + _, kwargs = gw.sessions.get_or_create.call_args + assert kwargs.get("model") == "job-pinned-model" + + class TestThrottleFallbackCronWiring: """agent.fallback_model reaches the cron turn, and a fallback-served run is visibly annotated (never silent).""" diff --git a/test/test_cron_handler_json_contract.py b/test/test_cron_handler_json_contract.py index 8fbae251b4c..3dc976c34cd 100644 --- a/test/test_cron_handler_json_contract.py +++ b/test/test_cron_handler_json_contract.py @@ -78,9 +78,14 @@ async def test_cron_update_refuses_a_non_object_body(payload) -> None: async def test_cron_update_keeps_the_object_path() -> None: """The guard must refuse only non-objects: a real patch still reaches the store.""" - job = SimpleNamespace(id="job-1", agent_id="", to_dict=lambda: {"id": "job-1"}) + job = SimpleNamespace(id="job-1", agent_id="", project_path="", to_dict=lambda: {"id": "job-1"}) update = AsyncMock(return_value=job) - app = _cron_app(api_cron_update, "/api/crons/{job_id}", update_job_async=update) + # The job-level owner gate fetches the job first via get_job_async; + # project_path="" (unbound) makes it pass through unconditionally. + get_job = AsyncMock(return_value=job) + app = _cron_app( + api_cron_update, "/api/crons/{job_id}", update_job_async=update, get_job_async=get_job + ) async with TestClient(TestServer(app)) as client: response = await client.patch("/api/crons/job-1", json={"name": "renamed"}) @@ -125,24 +130,51 @@ async def test_cron_enable_treats_an_absent_body_as_defaults() -> None: """No body at all still means the route's defaults -- the tolerant half of the old contract that ``allow_absent`` preserves.""" enable = AsyncMock(return_value=True) - app = _cron_app(api_cron_enable, "/api/crons/{job_id}/enable", enable_job_async=enable) + # The re-enable owner gate fetches the job first via get_job_async; + # project_path="" (unbound) makes it pass through unconditionally. + job = SimpleNamespace(id="job-1", project_path="") + get_job = AsyncMock(return_value=job) + app = _cron_app( + api_cron_enable, + "/api/crons/{job_id}/enable", + enable_job_async=enable, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: response = await client.post("/api/crons/job-1/enable") assert response.status == 200 - enable.assert_awaited_once_with("job-1", enabled=True) + # expect_project_path="" is passed because this request resolves as + # non-owner (a plain TestClient call carries no owner markers) against + # an unbound job's TOCTOU precondition. + enable.assert_awaited_once_with("job-1", enabled=True, expect_project_path="") async def test_cron_enable_still_reads_an_object_body() -> None: enable = AsyncMock(return_value=True) - app = _cron_app(api_cron_enable, "/api/crons/{job_id}/enable", enable_job_async=enable) + job = SimpleNamespace(id="job-1", project_path="") + get_job = AsyncMock(return_value=job) + app = _cron_app( + api_cron_enable, + "/api/crons/{job_id}/enable", + enable_job_async=enable, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: response = await client.post("/api/crons/job-1/enable", json={"enabled": False}) assert response.status == 200 - enable.assert_awaited_once_with("job-1", enabled=False) + # expect_project_path is always passed (as the _UNSET sentinel for the + # disable direction, which never fetches the job) -- assert on the + # positional/enabled args and check the kwarg is present rather than + # importing the sentinel object to match it exactly. + enable.assert_awaited_once() + call_args = enable.await_args + assert call_args.args == ("job-1",) + assert call_args.kwargs["enabled"] is False + assert "expect_project_path" in call_args.kwargs @pytest.mark.parametrize("payload", NON_OBJECT_BODIES) diff --git a/test/test_cron_minimal_context_api.py b/test/test_cron_minimal_context_api.py index 00c642701fc..02c0d1b0f9e 100644 --- a/test/test_cron_minimal_context_api.py +++ b/test/test_cron_minimal_context_api.py @@ -129,7 +129,15 @@ async def test_a_job_without_the_flag_reports_false_rather_than_omitting_it(self class TestUpdate: async def test_the_field_is_forwarded(self) -> None: update = AsyncMock(return_value=_job(minimal_context=True)) - app = _app(api_cron_update, "/api/crons/{job_id}", update_job_async=update) + # The job-level owner gate fetches the job first via get_job_async; + # the real CronJob's default project_path="" makes it pass through. + get_job = AsyncMock(return_value=_job()) + app = _app( + api_cron_update, + "/api/crons/{job_id}", + update_job_async=update, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: resp = await client.patch("/api/crons/j1", json={"minimal_context": True}) assert resp.status == 200 @@ -137,7 +145,13 @@ async def test_the_field_is_forwarded(self) -> None: async def test_turning_it_back_off_is_forwarded_rather_than_read_as_absent(self) -> None: update = AsyncMock(return_value=_job(minimal_context=False)) - app = _app(api_cron_update, "/api/crons/{job_id}", update_job_async=update) + get_job = AsyncMock(return_value=_job()) + app = _app( + api_cron_update, + "/api/crons/{job_id}", + update_job_async=update, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: resp = await client.patch("/api/crons/j1", json={"minimal_context": False}) assert resp.status == 200 @@ -145,7 +159,13 @@ async def test_turning_it_back_off_is_forwarded_rather_than_read_as_absent(self) async def test_an_unrelated_patch_leaves_the_flag_alone(self) -> None: update = AsyncMock(return_value=_job(minimal_context=True)) - app = _app(api_cron_update, "/api/crons/{job_id}", update_job_async=update) + get_job = AsyncMock(return_value=_job()) + app = _app( + api_cron_update, + "/api/crons/{job_id}", + update_job_async=update, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: resp = await client.patch("/api/crons/j1", json={"name": "renamed"}) assert resp.status == 200 diff --git a/test/test_cron_patch_project_path_validation.py b/test/test_cron_patch_project_path_validation.py new file mode 100644 index 00000000000..6b66caf0d6e --- /dev/null +++ b/test/test_cron_patch_project_path_validation.py @@ -0,0 +1,122 @@ +"""Tests for cron ``project_path`` validation on the dashboard PATCH surface. + +``POST /api/crons`` validates ``project_path`` via ``validate_string_field`` +(type check + sanitize + length cap), matching every other cron string field. +``PATCH /api/crons/{id}`` must route ``project_path`` through the same +validation instead of copying the raw body value straight to +``kwargs["project_path"]`` with only a truthiness check and an unguarded +``.strip()`` — a non-string JSON value (array/object/number) would otherwise +raise ``AttributeError`` inside the handler and surface as an HTTP 500 instead +of a clean 400. Same surface-divergence defect class as ``name``/``message`` +(see ``test_cron_patch_name_validation.py``, ``test_cron_message_cap.py``). + +Locks in that PATCH now routes ``project_path`` through the same validator as +POST, so the two REST surfaces cannot diverge and a malformed body never +reaches ``CronService.update_job_async``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from body_stream_helpers import attach_body + +from kiro_crew.cron import CronService +from kiro_crew.dashboard.handlers import api_cron_update +from kiro_crew.validation import MAX_SHORT_STRING + +OVERSIZE_PATH = "/" + "x" * MAX_SHORT_STRING + + +@pytest.fixture(autouse=True) +def _isolate_cron_store(monkeypatch, tmp_path): + monkeypatch.setattr("kiro_crew.cron._DEFAULT_DIR", tmp_path) + yield + + +def _create_request(body: dict, crons: CronService) -> MagicMock: + state = MagicMock() + state.crons = crons + request = MagicMock() + request.app = {"state": state} + attach_body(request, body) + return request + + +def _update_request(body: dict, crons: CronService, job_id: str) -> MagicMock: + request = _create_request(body, crons) + request.match_info = {"job_id": job_id} + return request + + +class TestDashboardUpdateProjectPath: + @pytest.mark.asyncio + async def test_patch_accepts_valid_path(self, tmp_path): + # project_path requires owner authorization (see + # test_cron_project_path_owner_gate.py) -- simulate an owner request + # so this test continues to exercise the validator, not the gate. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update( + _update_request({"project_path": str(tmp_path)}, crons, job.id) + ) + assert resp.status == 200 + assert crons.list_jobs()[0].project_path == str(tmp_path) + + @pytest.mark.asyncio + async def test_patch_rejects_path_beyond_cap(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update( + _update_request({"project_path": OVERSIZE_PATH}, crons, job.id) + ) + assert resp.status == 400 + assert b"invalid_project_path" in resp.body + assert crons.list_jobs()[0].project_path == "" + + @pytest.mark.asyncio + async def test_patch_rejects_non_string_path(self, tmp_path): + # An array/object/number JSON value must 400, not raise AttributeError + # on .strip() and leak an HTTP 500. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + resp = await api_cron_update(_update_request({"project_path": [1, 2]}, crons, job.id)) + assert resp.status == 400 + assert b"invalid_project_path" in resp.body + assert crons.list_jobs()[0].project_path == "" + + @pytest.mark.asyncio + async def test_patch_rejects_falsy_non_string_path(self, tmp_path): + """A falsy non-string (0) must 400, not silently no-op with a 200.""" + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + resp = await api_cron_update(_update_request({"project_path": 0}, crons, job.id)) + assert resp.status == 400 + assert b"invalid_project_path" in resp.body + assert crons.list_jobs()[0].project_path == "" + + @pytest.mark.asyncio + async def test_patch_clears_path_with_empty_string(self, tmp_path): + """An explicit empty string is a valid update that clears an existing + binding back to global-agent-only -- distinct from the field being + absent from the body entirely. Clearing requires owner authorization + (see test_cron_project_path_owner_gate.py), so simulate an owner + request here to keep this test focused on the clearing semantics.""" + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update(_update_request({"project_path": ""}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].project_path == "" diff --git a/test/test_cron_project_bound_job_owner_gate.py b/test/test_cron_project_bound_job_owner_gate.py new file mode 100644 index 00000000000..9746d49bfa8 --- /dev/null +++ b/test/test_cron_project_bound_job_owner_gate.py @@ -0,0 +1,130 @@ +"""Owner gate on a project-BOUND job's mutation and execution. + +Gating only the ``project_path`` field itself (see +``test_cron_project_path_owner_gate.py``) protected the BINDING but not the +already-bound JOB: a non-owner could still rewrite an owner-bound job's +``message`` (unrelated field, no field-level gate) via ``PATCH +/api/crons/{id}``, or trigger it directly via ``POST /api/crons/{id}/run`` +(no owner gate at all) -- either way the job later executes with +``job.project_path`` as its cwd and can read that project's files back, +without the request ever mentioning ``project_path``. These tests lock in +that BOTH routes require owner authorization once a job HAS a persisted +``project_path`` binding, regardless of which field the request body +touches. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from body_stream_helpers import attach_body + +from kiro_crew.cron import CronService +from kiro_crew.dashboard.handlers import api_cron_run, api_cron_update + + +def _update_request(body: dict, crons: CronService, job_id: str) -> MagicMock: + state = MagicMock() + state.crons = crons + request = MagicMock() + request.app = {"state": state} + request.match_info = {"job_id": job_id} + attach_body(request, body) + return request + + +def _run_request(crons: CronService, job_id: str) -> MagicMock: + state = MagicMock() + state.crons = crons + request = MagicMock() + request.app = {"state": state} + request.match_info = {"job_id": job_id} + return request + + +@pytest.fixture(autouse=True) +def _isolate_cron_store(monkeypatch, tmp_path): + monkeypatch.setattr("kiro_crew.cron._DEFAULT_DIR", tmp_path) + yield + + +class TestProjectBoundJobUpdateOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_cannot_edit_message_on_a_bound_job(self, tmp_path): + # The request never mentions project_path at all -- only message. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update(_update_request({"message": "new"}, crons, job.id)) + assert resp.status == 403 + assert ( + crons.list_jobs()[0].message == "m" + ), "a denied edit must leave the bound job's message unchanged" + + @pytest.mark.asyncio + async def test_owner_can_still_edit_message_on_a_bound_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update(_update_request({"message": "new"}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].message == "new" + + @pytest.mark.asyncio + async def test_non_owner_can_still_edit_message_on_an_unbound_job(self, tmp_path): + # No project_path binding on the job -- an ordinary edit must be + # unaffected by this gate. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update(_update_request({"message": "new"}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].message == "new" + + +class TestProjectBoundJobRunOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_cannot_trigger_a_bound_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_run(_run_request(crons, job.id)) + assert resp.status == 403 + assert ( + job.id not in crons._running_tasks + ), "a denied trigger must not start a run task at all" + + @pytest.mark.asyncio + async def test_owner_can_still_trigger_a_bound_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_run(_run_request(crons, job.id)) + assert resp.status == 200 + + @pytest.mark.asyncio + async def test_non_owner_can_still_trigger_an_unbound_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_run(_run_request(crons, job.id)) + assert resp.status == 200 diff --git a/test/test_cron_project_bound_job_toctou.py b/test/test_cron_project_bound_job_toctou.py new file mode 100644 index 00000000000..f41826d4213 --- /dev/null +++ b/test/test_cron_project_bound_job_toctou.py @@ -0,0 +1,224 @@ +"""Owner gate on RE-ENABLING a project-bound job, and the update-path TOCTOU. + +Two closely related follow-on findings after the create/update/run gates +(see ``test_cron_project_bound_job_owner_gate.py``): + +1. ``api_cron_enable`` had NO owner gate at all -- a non-owner could + re-enable an owner-disabled project-bound job, handing it back to the + scheduler, which fires it against ``job.project_path`` the same as a + manual trigger. Disabling carries no equivalent risk (it stops execution + rather than starting it), so only the re-enable direction is gated. + +2. The owner-authorization decision in ``api_cron_update`` (and now + ``api_cron_enable``) reads the job's CURRENT ``project_path`` outside any + lock, then applies the mutation in a SEPARATE later lock acquisition. A + concurrent owner bind/unbind landing in that gap would let a non-owner's + already-authorized (against the stale snapshot) request execute against + a binding it was never actually checked against. Closed with an + ``expect_project_path`` precondition re-verified atomically UNDER the + lock, mirroring the existing ``expect_secret_env`` compare-and-swap. +""" + +from __future__ import annotations + +import dataclasses +from unittest.mock import MagicMock, patch + +import pytest +from body_stream_helpers import attach_body + +from kiro_crew.cron import CronPendingMismatch, CronService +from kiro_crew.dashboard.handlers import api_cron_enable, api_cron_update + + +def _enable_request(body: dict, crons: CronService, job_id: str) -> MagicMock: + state = MagicMock() + state.crons = crons + request = MagicMock() + request.app = {"state": state} + request.match_info = {"job_id": job_id} + attach_body(request, body) + return request + + +def _update_request(body: dict, crons: CronService, job_id: str) -> MagicMock: + return _enable_request(body, crons, job_id) + + +@pytest.fixture(autouse=True) +def _isolate_cron_store(monkeypatch, tmp_path): + monkeypatch.setattr("kiro_crew.cron._DEFAULT_DIR", tmp_path) + yield + + +class TestProjectBoundJobEnableOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_cannot_reenable_a_bound_disabled_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job( + name="n", + message="m", + every_secs=3600, + project_path=str(tmp_path), + enabled=False, + ) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_enable(_enable_request({"enabled": True}, crons, job.id)) + assert resp.status == 403 + assert ( + crons.list_jobs(include_disabled=True)[0].enabled is False + ), "a denied re-enable must leave the job disabled" + + @pytest.mark.asyncio + async def test_owner_can_still_reenable_a_bound_disabled_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job( + name="n", + message="m", + every_secs=3600, + project_path=str(tmp_path), + enabled=False, + ) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_enable(_enable_request({"enabled": True}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].enabled is True + + @pytest.mark.asyncio + async def test_non_owner_can_still_disable_a_bound_job(self, tmp_path): + # Only the RE-ENABLE direction is gated -- disabling stops execution + # rather than starting it, so it must be unaffected. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_enable(_enable_request({"enabled": False}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs(include_disabled=True)[0].enabled is False + + @pytest.mark.asyncio + async def test_non_owner_can_still_reenable_an_unbound_job(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, enabled=False) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_enable(_enable_request({"enabled": True}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].enabled is True + + +class TestUpdatePathBindingToctou: + @pytest.mark.asyncio + async def test_a_concurrent_bind_between_check_and_write_is_rejected_not_applied( + self, + tmp_path, + ): + # Simulates the race: the outer gate reads an UNBOUND snapshot (so a + # non-owner's message edit is allowed through), but by the time the + # actual mutation reaches the locked core, an owner has concurrently + # bound the job to a project. The precondition must catch this and + # refuse, rather than let the non-owner's edit land on the + # newly-bound job. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + + real_get_job_async = crons.get_job_async + + async def _get_job_then_mutate_concurrently(job_id: str): + result = await real_get_job_async(job_id) + # A snapshot returned to a DIFFERENT concurrent request would be + # an independent CronJob instance (each request's own disk + # read/parse produces its own objects) -- copy here so the + # mutation below cannot retroactively change the value this + # request already captured, which would defeat the point of the + # simulation. + snapshot = dataclasses.replace(result) + # Simulate the concurrent owner bind landing right after the + # snapshot read this handler's gate just took, before the + # handler's own later update_job_async call. + crons._update_job_locked(job_id, project_path=str(tmp_path)) + return snapshot + + crons.get_job_async = _get_job_then_mutate_concurrently # type: ignore[method-assign] + + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update(_update_request({"message": "new"}, crons, job.id)) + assert resp.status == 409 + assert ( + crons.list_jobs()[0].message == "m" + ), "the stale-precondition rejection must leave the message unchanged" + + def test_the_locked_core_raises_on_a_project_path_mismatch(self, tmp_path): + # Direct unit check of the precondition itself, independent of the + # handler-level race simulation above. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with pytest.raises(CronPendingMismatch): + crons._update_job_locked(job.id, message="new", expect_project_path="") + + def test_the_locked_core_accepts_a_matching_project_path_precondition(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + updated = crons._update_job_locked( + job.id, + message="new", + expect_project_path=str(tmp_path), + ) + assert updated is not None + assert updated.message == "new" + + +class TestRunPathBindingToctou: + """``run_job`` re-syncs its OWN fresh snapshot from disk once its task + actually starts, independently of the REST handler's earlier check -- so + the handler's "no await between check and dispatch" property (which does + close the DISPATCH itself against interleaving) does NOT close this + second, later read inside the dispatched task. Confirmed by reading + ``run_job``'s actual body: ``_synced_snapshot(True)`` is a fresh disk + read, not the snapshot the handler already checked. + """ + + @pytest.mark.asyncio + async def test_run_refuses_when_the_binding_changed_before_the_task_actually_starts( + self, + tmp_path, + ): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + # Simulate the owner binding the project between the handler's check + # (which saw it unbound) and run_job's own later re-sync. + crons._update_job_locked(job.id, project_path=str(tmp_path)) + ok = await crons.run_job(job.id, expect_project_path="") + assert ok is False + assert ( + job.id not in crons._executing + ), "a refused run must never actually execute against the newly-bound project" + + @pytest.mark.asyncio + async def test_run_still_executes_when_the_binding_is_unchanged(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + ok = await crons.run_job(job.id, expect_project_path=str(tmp_path)) + assert ok is True + + @pytest.mark.asyncio + async def test_run_is_unaffected_without_a_precondition(self, tmp_path): + # The owner's own request path passes no precondition at all -- an + # unbound job must run normally regardless. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + ok = await crons.run_job(job.id) + assert ok is True diff --git a/test/test_cron_project_path_owner_gate.py b/test/test_cron_project_path_owner_gate.py new file mode 100644 index 00000000000..306f88eb6d0 --- /dev/null +++ b/test/test_cron_project_path_owner_gate.py @@ -0,0 +1,175 @@ +"""Owner gate on ``project_path`` for cron create/update. + +``project_path`` binds a job's agent to an arbitrary cwd at fire time, so it +fires the agent against, and can return, that project's files. Before this +fix, ``POST /api/crons`` and ``PATCH /api/crons/{id}`` accepted a non-empty +``project_path`` from ANY caller: an allow-listed non-owner dashboard token +(``app == ""``, which sails through every app-token check) could create or +edit a job binding its cwd to another project and later read that project's +files back through the job's output. This is the write-side counterpart to +the read-side gate on ``GET /api/agents?project_path`` (see +``test_agents_project_path_owner_gate.py``): both routes now require +``is_owner_dashboard_request`` for a non-empty ``project_path``, but they +disagree on treatment by design -- the read-side fallback silently ignores a +non-owner's ``project_path`` (a probe must not learn "gate exists" from an +error, since the field is optional there), while these mutating routes +reject outright with 403 (a create/update has no silent-success shape that +also drops the field, and the caller must clearly hear that the write did not +happen as requested rather than persist a job with an empty ``project_path`` +they thought was set). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from body_stream_helpers import attach_body + +from kiro_crew.cron import CronService +from kiro_crew.dashboard.handlers import api_cron_update, api_crons_create + + +def _create_request(body: dict, crons: CronService) -> MagicMock: + state = MagicMock() + state.crons = crons + request = MagicMock() + request.app = {"state": state} + attach_body(request, body) + return request + + +def _update_request(body: dict, crons: CronService, job_id: str) -> MagicMock: + request = _create_request(body, crons) + request.match_info = {"job_id": job_id} + return request + + +@pytest.fixture(autouse=True) +def _isolate_cron_store(monkeypatch, tmp_path): + monkeypatch.setattr("kiro_crew.cron._DEFAULT_DIR", tmp_path) + yield + + +class TestCronCreateProjectPathOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_create_with_project_path_is_denied(self, tmp_path): + crons = CronService(base_dir=tmp_path) + request = _create_request( + {"name": "n", "message": "m", "every": 3600, "project_path": str(tmp_path)}, + crons, + ) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_crons_create(request) + assert resp.status == 403 + assert crons.list_jobs() == [], ( + "a denied create must not persist a job at all, not a job with " + "project_path silently dropped" + ) + + @pytest.mark.asyncio + async def test_owner_create_with_project_path_still_succeeds(self, tmp_path): + crons = CronService(base_dir=tmp_path) + request = _create_request( + {"name": "n", "message": "m", "every": 3600, "project_path": str(tmp_path)}, + crons, + ) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_crons_create(request) + assert resp.status == 200 + jobs = crons.list_jobs() + assert len(jobs) == 1 + assert jobs[0].project_path == str(tmp_path) + + @pytest.mark.asyncio + async def test_non_owner_create_without_project_path_still_succeeds(self, tmp_path): + # The gate is scoped to a non-empty project_path -- an ordinary + # non-owner create with no project_path at all must be unaffected. + crons = CronService(base_dir=tmp_path) + request = _create_request({"name": "n", "message": "m", "every": 3600}, crons) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_crons_create(request) + assert resp.status == 200 + + +class TestCronUpdateProjectPathOwnerGate: + @pytest.mark.asyncio + async def test_non_owner_update_with_project_path_is_denied(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update( + _update_request({"project_path": str(tmp_path)}, crons, job.id) + ) + assert resp.status == 403 + assert crons.list_jobs()[0].project_path == "", ( + "a denied update must leave the job's project_path unset, not " "silently apply it" + ) + + @pytest.mark.asyncio + async def test_owner_update_with_project_path_still_succeeds(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update( + _update_request({"project_path": str(tmp_path)}, crons, job.id) + ) + assert resp.status == 200 + assert crons.list_jobs()[0].project_path == str(tmp_path) + + @pytest.mark.asyncio + async def test_non_owner_update_clearing_project_path_is_denied(self, tmp_path): + # An empty-string project_path on update CLEARS an existing binding -- + # a privileged mutation of the same field as setting it, so it must + # be gated the same way. Gating on the validated value's truthiness + # (rather than the field's presence in the body) let this slip past + # the check entirely: caught by review. + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update(_update_request({"project_path": ""}, crons, job.id)) + assert resp.status == 403 + assert crons.list_jobs()[0].project_path == str( + tmp_path + ), "a denied clear must leave the existing owner-set binding intact" + + @pytest.mark.asyncio + async def test_owner_update_can_clear_project_path(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600, project_path=str(tmp_path)) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: True, + ): + resp = await api_cron_update(_update_request({"project_path": ""}, crons, job.id)) + assert resp.status == 200 + assert crons.list_jobs()[0].project_path == "" + + @pytest.mark.asyncio + async def test_non_owner_update_without_project_path_still_succeeds(self, tmp_path): + crons = CronService(base_dir=tmp_path) + job = crons.add_job(name="n", message="m", every_secs=3600) + with patch( + "kiro_crew.dashboard.handlers.cron.is_owner_dashboard_request", + lambda request: False, + ): + resp = await api_cron_update(_update_request({"name": "renamed"}, crons, job.id)) + assert resp.status == 200 diff --git a/test/test_cron_refusal_status.py b/test/test_cron_refusal_status.py index 0f30448150a..d1a3ba9c39c 100644 --- a/test/test_cron_refusal_status.py +++ b/test/test_cron_refusal_status.py @@ -57,6 +57,7 @@ def _run_cron_runs( gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw.sessions.get_or_create = AsyncMock(return_value=(MagicMock(), True, False)) gw.sessions.release = MagicMock() @@ -104,9 +105,10 @@ async def fake_stream(client, msg, **kwargs): captured_cb = None - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb diff --git a/test/test_cron_run_failure_alert.py b/test/test_cron_run_failure_alert.py index 032c552a6ca..9daf180cfca 100644 --- a/test/test_cron_run_failure_alert.py +++ b/test/test_cron_run_failure_alert.py @@ -41,6 +41,7 @@ def _make_gw(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._running_script_ids = set() gw._no_crons = False gw.cron_svc = MagicMock() diff --git a/test/test_cron_slack_delivery.py b/test/test_cron_slack_delivery.py index 51e700916b3..77b5c131878 100644 --- a/test/test_cron_slack_delivery.py +++ b/test/test_cron_slack_delivery.py @@ -29,6 +29,7 @@ def _make_gateway(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw.sessions.get_or_create = AsyncMock(return_value=(MagicMock(), True, False)) gw.sessions.release = MagicMock() @@ -61,9 +62,10 @@ async def fake_stream(client, msg, **kwargs): raise stream_side_effect return stream_result - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_cb @@ -119,9 +121,10 @@ def test_dashboard_notify_calls_redaction(self) -> None: gw.slack = None # skip Slack path job = _make_job() - with patch("kiro_crew.slack.gateway.redact_exfiltration_urls") as mock_url, patch( - "kiro_crew.slack.gateway.redact_credentials" - ) as mock_cred: + with ( + patch("kiro_crew.slack.gateway.redact_exfiltration_urls") as mock_url, + patch("kiro_crew.slack.gateway.redact_credentials") as mock_cred, + ): mock_url.return_value = ("redacted_url", False) mock_cred.return_value = ("fully_redacted", False) _run_callback(gw, job, stream_result="secret http://evil.com data") diff --git a/test/test_cron_source_preset_api.py b/test/test_cron_source_preset_api.py index 77ed8287615..62d579a9763 100644 --- a/test/test_cron_source_preset_api.py +++ b/test/test_cron_source_preset_api.py @@ -150,7 +150,13 @@ async def test_source_preset_is_not_forwarded_on_patch(self) -> None: """Create-only: even if a client sends it, PATCH must not pass it to the store (provenance is fixed at creation).""" update = AsyncMock(return_value=_job(source_preset="error-digest")) - app = _app(api_cron_update, "/api/crons/{job_id}", update_job_async=update) + get_job = AsyncMock(return_value=_job()) + app = _app( + api_cron_update, + "/api/crons/{job_id}", + update_job_async=update, + get_job_async=get_job, + ) async with TestClient(TestServer(app)) as client: resp = await client.patch( "/api/crons/j1", json={"name": "renamed", "source_preset": "standup-brief"} diff --git a/test/test_cron_string_field_validation.py b/test/test_cron_string_field_validation.py index 1677bdf9b7c..cebf983059e 100644 --- a/test/test_cron_string_field_validation.py +++ b/test/test_cron_string_field_validation.py @@ -277,6 +277,13 @@ class TestCapAlignment: # Dashboard-only prompt snapshot (message-sized cap), same reasoning: # written by the create handler, no CRON_ADD_SCHEMA entry. "source_template_prompt", + # project_path is intentionally NOT in CRON_ADD_SCHEMA/CRON_UPDATE_SCHEMA + # -- it is a dashboard/REST-only field (POST/PATCH /api/crons via + # validate_string_field). Advertising it in an MCP tool schema without + # the handler reading it back would let an LLM agent set a value that + # is silently dropped -- MCP's cron_add/cron_update deliberately do + # not accept it. + "project_path", # Secret-grant pins and the requesting session key are written only by # the grant endpoint / cron_secret_request tool, never via # CRON_ADD_SCHEMA (grants cannot be created through cron_add). diff --git a/test/test_cron_thread_routing.py b/test/test_cron_thread_routing.py index 93c8b5b1012..70a7040daa0 100644 --- a/test/test_cron_thread_routing.py +++ b/test/test_cron_thread_routing.py @@ -30,6 +30,7 @@ def _make_gateway(): gateway.dashboard_state = MagicMock() gateway._owner_id = "U000" gateway._cron_injecting = {} + gateway._cron_session_binding = {} gateway._no_crons = False gateway.subagent_mgr = MagicMock() gateway.subagent_mgr.running = [] @@ -69,9 +70,10 @@ def _run_callback(gateway, job, stream_result="done"): async def fake_stream(client, msg, **kwargs): return stream_result - with patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), patch( - "kiro_crew.slack.gateway.CronService" - ) as mock_cron_cls: + with ( + patch("kiro_crew.slack.gateway.stream_and_collect", fake_stream), + patch("kiro_crew.slack.gateway.CronService") as mock_cron_cls, + ): def capture_cron(on_job=None, **kw): nonlocal captured_callback @@ -262,7 +264,9 @@ def _patches(self): async def test_cron_injection_cancels_before_release(self) -> None: gateway = _make_gateway() call_order: list[str] = [] - gateway.sessions.cancel_current = AsyncMock(side_effect=lambda k: call_order.append("cancel")) + gateway.sessions.cancel_current = AsyncMock( + side_effect=lambda k: call_order.append("cancel") + ) gateway.sessions.release = MagicMock(side_effect=lambda k: call_order.append("release")) subagent_done = _capture_subagent_done(gateway) info = SubagentInfo(id="s1", task="work", parent_session_key="cron:j1") @@ -270,8 +274,13 @@ async def test_cron_injection_cancels_before_release(self) -> None: info.done = True p1, p2 = self._patches() with ( - patch("kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="ok"), - p1, p2, + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="ok", + ), + p1, + p2, ): await subagent_done(info) assert call_order == ["cancel", "release"] @@ -282,7 +291,9 @@ async def test_slack_injection_cancels_before_release(self) -> None: gateway.slack.open_dm = AsyncMock(return_value="D123") gateway.slack.post_message = AsyncMock() call_order: list[str] = [] - gateway.sessions.cancel_current = AsyncMock(side_effect=lambda k: call_order.append("cancel")) + gateway.sessions.cancel_current = AsyncMock( + side_effect=lambda k: call_order.append("cancel") + ) gateway.sessions.release = MagicMock(side_effect=lambda k: call_order.append("release")) subagent_done = _capture_subagent_done(gateway) info = SubagentInfo(id="s3", task="work", parent_session_key="slack:U000") @@ -290,12 +301,17 @@ async def test_slack_injection_cancels_before_release(self) -> None: info.done = True p1, p2 = self._patches() with ( - patch("kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="ok"), + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="ok", + ), # gateway renders through the shared Slack pipeline now, so this is # the one seam to stub -- patching to_slack_mrkdwn/split_message # individually does not intercept anything. patch("kiro_crew.slack.gateway.render_for_slack", return_value=["ok"]), - p1, p2, + p1, + p2, ): await subagent_done(info) assert call_order == ["cancel", "release"] @@ -311,8 +327,13 @@ async def test_cancel_failure_does_not_prevent_release(self) -> None: info.done = True p1, p2 = self._patches() with ( - patch("kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="ok"), - p1, p2, + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="ok", + ), + p1, + p2, ): await subagent_done(info) gateway.sessions.release.assert_called_once_with("cron:j1") @@ -326,7 +347,9 @@ class TestDashboardInjectionRoutesRunChat: def _patches(self): return ( - patch("kiro_crew.slack.gateway.redact_exfiltration_urls", side_effect=lambda s: (s, False)), + patch( + "kiro_crew.slack.gateway.redact_exfiltration_urls", side_effect=lambda s: (s, False) + ), patch("kiro_crew.slack.gateway.redact_credentials", side_effect=lambda s: (s, False)), ) @@ -347,7 +370,8 @@ async def test_routes_through_run_chat_when_idle(self) -> None: p1, p2 = self._patches() with ( patch("kiro_crew.slack.gateway._run_chat", new_callable=AsyncMock), - p1, p2, + p1, + p2, ): await subagent_done(info) @@ -391,13 +415,16 @@ async def test_busy_slot_awaits_then_injects(self) -> None: _mock_run_chat = AsyncMock(return_value=None) with ( patch("kiro_crew.slack.gateway._run_chat", _mock_run_chat), - p1, p2, + p1, + p2, ): await subagent_done(info) # _run_chat should have been triggered assert _mock_run_chat.called, "_run_chat must be called after busy slot becomes idle" - assert slot.task is not _done_future, "slot.task must be reassigned to the new _run_chat task" + assert ( + slot.task is not _done_future + ), "slot.task must be reassigned to the new _run_chat task" @pytest.mark.asyncio async def test_busy_slot_timeout_queues_result(self) -> None: @@ -418,7 +445,8 @@ async def test_busy_slot_timeout_queues_result(self) -> None: p1, p2 = self._patches() with ( patch("kiro_crew.slack.gateway.INJECTION_TIMEOUT", 0.01), - p1, p2, + p1, + p2, ): await subagent_done(info) @@ -444,7 +472,8 @@ async def test_error_callback_notifies_with_redacted_reason(self) -> None: p1, p2 = self._patches() with ( patch("kiro_crew.slack.gateway._run_chat", _mock_run_chat), - p1, p2, + p1, + p2, ): await subagent_done(info) # Wait for the task's done callbacks to fire (may need multiple event-loop ticks under load) @@ -469,12 +498,13 @@ def test_persistent_session_with_slot_calls_inject(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=True) job = _make_job(persistent_session=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="cron output") mock_inject.assert_called_once_with( - gateway.dashboard_state, job, "cron output", history=ANY, + gateway.dashboard_state, + job, + "cron output", + history=ANY, context_reading=ANY, ) @@ -483,12 +513,13 @@ def test_persistent_session_without_slot_still_injects(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=False) job = _make_job(persistent_session=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="cron output") mock_inject.assert_called_once_with( - gateway.dashboard_state, job, "cron output", history=ANY, + gateway.dashboard_state, + job, + "cron output", + history=ANY, context_reading=ANY, ) @@ -497,9 +528,7 @@ def test_non_persistent_session_does_not_inject(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=True) job = _make_job(persistent_session=False) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="cron output") mock_inject.assert_not_called() @@ -511,9 +540,7 @@ def test_inject_fires_on_dedup_suppression(self) -> None: job = _make_job(persistent_session=True) # Simulate dedup: set last_posted_hash to match result job.last_posted_hash = "" # first run posts normally - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: # First run — normal path _run_callback(gateway, job, stream_result="same result") first_call_count = mock_inject.call_count @@ -531,12 +558,13 @@ def test_silent_cron_injects_to_existing_slot(self) -> None: gateway = _make_gateway() gateway.dashboard_state.has_slot = MagicMock(return_value=True) job = _make_job(persistent_session=True, silent=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="silent output") mock_inject.assert_called_once_with( - gateway.dashboard_state, job, "silent output", history=ANY, + gateway.dashboard_state, + job, + "silent output", + history=ANY, context_reading=ANY, ) @@ -545,9 +573,7 @@ def test_silent_cron_no_slot_does_not_inject(self) -> None: gateway = _make_gateway() gateway.dashboard_state.has_slot = MagicMock(return_value=False) job = _make_job(persistent_session=True, silent=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="silent output") mock_inject.assert_not_called() @@ -560,9 +586,7 @@ def test_hide_in_chat_suppresses_inject_on_normal_path(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=False) job = _make_job(persistent_session=True, hide_in_chat=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="hidden output") mock_inject.assert_not_called() @@ -573,12 +597,13 @@ def test_hide_in_chat_false_still_injects(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=False) job = _make_job(persistent_session=True, hide_in_chat=False) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="shown output") mock_inject.assert_called_once_with( - gateway.dashboard_state, job, "shown output", history=ANY, + gateway.dashboard_state, + job, + "shown output", + history=ANY, context_reading=ANY, ) @@ -589,9 +614,7 @@ def test_hide_in_chat_suppresses_inject_even_with_existing_slot(self) -> None: gateway.slack.post_blocks = AsyncMock(return_value="1711957800.001234") gateway.dashboard_state.has_slot = MagicMock(return_value=True) job = _make_job(persistent_session=True, hide_in_chat=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="hidden output") mock_inject.assert_not_called() @@ -601,9 +624,7 @@ def test_hide_in_chat_silent_cron_does_not_inject(self) -> None: gateway = _make_gateway() gateway.dashboard_state.has_slot = MagicMock(return_value=True) job = _make_job(persistent_session=True, silent=True, hide_in_chat=True) - with patch( - "kiro_crew.slack.gateway.inject_cron_result_to_dashboard" - ) as mock_inject: + with patch("kiro_crew.slack.gateway.inject_cron_result_to_dashboard") as mock_inject: _run_callback(gateway, job, stream_result="silent hidden output") mock_inject.assert_not_called() @@ -616,9 +637,7 @@ def _result_notify_meta(notify_mock): meta = call.kwargs.get("meta", {}) or {} if meta.get("job_id") == "j1" and "failure_hash" not in meta: return meta - raise AssertionError( - f"no cron-result notify found; calls={notify_mock.call_args_list}" - ) + raise AssertionError(f"no cron-result notify found; calls={notify_mock.call_args_list}") def test_hide_in_chat_notify_meta_omits_slot_even_with_existing_slot(self) -> None: """notify_meta['slot'] is gated on not hide_in_chat: a hidden cron that diff --git a/test/test_cron_wake_budget.py b/test/test_cron_wake_budget.py index e4a7fcfe716..1b462135fb3 100644 --- a/test/test_cron_wake_budget.py +++ b/test/test_cron_wake_budget.py @@ -153,6 +153,7 @@ def gw_and_cb() -> tuple[Any, Callable[[], Any], Callable[..., Any]]: gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._no_crons = False gw._interactive_approval = MagicMock(return_value="interactive_cb") diff --git a/test/test_dashboard_cron_approval.py b/test/test_dashboard_cron_approval.py index b35122eebb2..b3f64e0b3ae 100644 --- a/test/test_dashboard_cron_approval.py +++ b/test/test_dashboard_cron_approval.py @@ -248,6 +248,7 @@ async def test_response_includes_approval_mode_and_silent(self): mock_job.source_template_prompt = "" mock_job.member_id = "" mock_job.memory_store = "" + mock_job.project_path = "" mock_state = MagicMock() mock_state.has_slot.return_value = False @@ -271,3 +272,121 @@ async def test_response_includes_approval_mode_and_silent(self): assert job_data["skip_dates"] is None # server_tz top-level field exposes the dashboard's local TZ for client rendering assert "server_tz" in data + + @pytest.mark.asyncio + async def test_response_includes_project_path_field(self): + # A separate test from test_response_includes_approval_mode_and_silent + # (not a variant of it): this pins the exact regression that shipped + # once already — project_path was persisted and used correctly at + # fire time, but silently absent from THIS list serializer, so the + # Schedule page's "Operating folder" field always read back empty on + # every edit despite the value being saved. + mock_job = MagicMock() + mock_job.id = "j1" + mock_job.name = "test" + mock_job.message = "msg" + mock_job.enabled = True + mock_job.last_status = "ok" + mock_job.agent_id = "" + mock_job.channel = None + mock_job.approval_mode = "" + mock_job.silent = False + mock_job.strict_schedule = False + mock_job.hide_in_chat = False + mock_job.minimal_context = False + mock_job.schedule = CronSchedule(kind="every", every_secs=300) + mock_job.last_run_ts = None + mock_job.last_result = None + mock_job.last_retry_count = 0 + mock_job.last_retry_run_ts = 0.0 + mock_job.created_ts = None + mock_job.timezone = "" + mock_job.skip_dates = [] + mock_job.script = "" + mock_job.command = "" + mock_job.secret_env = {} + mock_job.secret_env_pending = {} + mock_job.secret_env_pending_ts = 0.0 + mock_job.last_error = "" + mock_job.model = "" + mock_job.folder_id = "" + mock_job.session_key = "" + mock_job.source_preset = "" + mock_job.source_template_prompt = "" + mock_job.member_id = "" + mock_job.memory_store = "" + mock_job.project_path = "/Users/dev/myrepo" + + mock_state = MagicMock() + mock_state.has_slot.return_value = False + mock_state.crons.list_jobs.return_value = [mock_job] + mock_state.crons.list_jobs_async = AsyncMock(return_value=[mock_job]) + mock_state.crons.running_since.return_value = None + mock_state.crons.is_running.return_value = False + + request = MagicMock() + request.app = {"state": mock_state} + + resp = await api_crons(request) + + data = json.loads(resp.body) + job_data = data["jobs"][0] + assert job_data["project_path"] == "/Users/dev/myrepo" + + @pytest.mark.asyncio + async def test_response_project_path_absent_reads_as_none(self): + # The empty-string default must serialize as `None`, matching every + # other optional string field in this response (approval_mode, + # channel, timezone, ...) — not an empty string a client would have + # to special-case differently from the rest. + mock_job = MagicMock() + mock_job.id = "j2" + mock_job.name = "test2" + mock_job.message = "msg" + mock_job.enabled = True + mock_job.last_status = "ok" + mock_job.agent_id = "" + mock_job.channel = None + mock_job.approval_mode = "" + mock_job.silent = False + mock_job.strict_schedule = False + mock_job.hide_in_chat = False + mock_job.minimal_context = False + mock_job.schedule = CronSchedule(kind="every", every_secs=300) + mock_job.last_run_ts = None + mock_job.last_result = None + mock_job.last_retry_count = 0 + mock_job.last_retry_run_ts = 0.0 + mock_job.created_ts = None + mock_job.timezone = "" + mock_job.skip_dates = [] + mock_job.script = "" + mock_job.command = "" + mock_job.secret_env = {} + mock_job.secret_env_pending = {} + mock_job.secret_env_pending_ts = 0.0 + mock_job.last_error = "" + mock_job.model = "" + mock_job.folder_id = "" + mock_job.session_key = "" + mock_job.source_preset = "" + mock_job.source_template_prompt = "" + mock_job.member_id = "" + mock_job.memory_store = "" + mock_job.project_path = "" + + mock_state = MagicMock() + mock_state.has_slot.return_value = False + mock_state.crons.list_jobs.return_value = [mock_job] + mock_state.crons.list_jobs_async = AsyncMock(return_value=[mock_job]) + mock_state.crons.running_since.return_value = None + mock_state.crons.is_running.return_value = False + + request = MagicMock() + request.app = {"state": mock_state} + + resp = await api_crons(request) + + data = json.loads(resp.body) + job_data = data["jobs"][0] + assert job_data["project_path"] is None diff --git a/test/test_dashboard_cron_folder_id.py b/test/test_dashboard_cron_folder_id.py index dd161746d7b..93ac76c2b70 100644 --- a/test/test_dashboard_cron_folder_id.py +++ b/test/test_dashboard_cron_folder_id.py @@ -79,6 +79,12 @@ def _update_request(self, body: dict, job_id: str = "abc123") -> MagicMock: mock_job = MagicMock() mock_job.id = job_id state.crons.update_job_async = AsyncMock(return_value=mock_job) + # The job-level owner gate fetches the job first via get_job_async; + # an unconfigured MagicMock attribute is not awaitable. project_path="" + # (unbound) makes the gate pass through unconditionally. + existing_job = MagicMock() + existing_job.project_path = "" + state.crons.get_job_async = AsyncMock(return_value=existing_job) request = MagicMock() request.app = {"state": state} request.match_info = {"job_id": job_id} diff --git a/test/test_dashboard_cron_hide_in_chat.py b/test/test_dashboard_cron_hide_in_chat.py index 81d55383dea..98afc42d513 100644 --- a/test/test_dashboard_cron_hide_in_chat.py +++ b/test/test_dashboard_cron_hide_in_chat.py @@ -81,6 +81,11 @@ def _update_request(self, body: dict, job_id: str = "abc123") -> MagicMock: mock_job = MagicMock() mock_job.id = job_id state.crons.update_job_async = AsyncMock(return_value=mock_job) + # The job-level owner gate fetches the job first via get_job_async; + # project_path="" (unbound) makes it pass through unconditionally. + existing_job = MagicMock() + existing_job.project_path = "" + state.crons.get_job_async = AsyncMock(return_value=existing_job) request = MagicMock() request.app = {"state": state} request.match_info = {"job_id": job_id} diff --git a/test/test_dashboard_cron_run_guard.py b/test/test_dashboard_cron_run_guard.py index 62d299d0352..81310bf6e80 100644 --- a/test/test_dashboard_cron_run_guard.py +++ b/test/test_dashboard_cron_run_guard.py @@ -65,7 +65,11 @@ async def test_run_idle_job_starts(self) -> None: assert data["ok"] is True # A run was started (the task may already have finished and been popped # by the done-callback, so assert the invocation rather than the dict). - state.crons.run_job.assert_called_once_with("j1") + # `expect_project_path=""` is passed because this request resolves as + # non-owner (a plain TestClient call carries no owner markers) against + # an unbound job -- see test_cron_project_bound_job_toctou.py for the + # TOCTOU this closes. + state.crons.run_job.assert_called_once_with("j1", expect_project_path="") @pytest.mark.asyncio async def test_run_unknown_job_404(self) -> None: @@ -113,7 +117,7 @@ async def test_run_finds_job_absent_from_the_cache_only_snapshot(self) -> None: resp = await client.post("/api/crons/just-created/run") assert resp.status == 200 state.crons.get_job_async.assert_awaited_once_with("just-created") - state.crons.run_job.assert_called_once_with("just-created") + state.crons.run_job.assert_called_once_with("just-created", expect_project_path="") @pytest.mark.asyncio async def test_concurrent_runs_still_yield_one_200_and_one_409(self) -> None: @@ -142,4 +146,4 @@ async def _blocked_run(_job_id: str) -> bool: finally: gate.set() # Exactly one run was started despite both requests passing the lookup. - state.crons.run_job.assert_called_once_with("j1") + state.crons.run_job.assert_called_once_with("j1", expect_project_path="") diff --git a/test/test_dashboard_cron_update_agent.py b/test/test_dashboard_cron_update_agent.py index fb979a9bfd1..504dae92c7e 100644 --- a/test/test_dashboard_cron_update_agent.py +++ b/test/test_dashboard_cron_update_agent.py @@ -23,6 +23,16 @@ def _make_request(body: dict, job_id: str = "abc123") -> MagicMock: mock_job = MagicMock() mock_job.id = job_id mock_state.crons.update_job_async = AsyncMock(return_value=mock_job) + # The job-level owner gate (api_cron_update) fetches the job first via + # get_job_async to decide whether the request needs owner authorization + # -- an unconfigured MagicMock attribute is not awaitable, so every test + # here would otherwise fail with "object MagicMock can't be used in + # 'await' expression" before ever reaching the agent-mapping logic these + # tests exist to cover. project_path="" (unbound) makes the gate pass + # through unconditionally, matching every job these tests construct. + existing_job = MagicMock() + existing_job.project_path = "" + mock_state.crons.get_job_async = AsyncMock(return_value=existing_job) request = MagicMock() request.app = {"state": mock_state} diff --git a/test/test_slack_cron_remove_audit.py b/test/test_slack_cron_remove_audit.py index fc856ca4cc9..28842d22e8d 100644 --- a/test/test_slack_cron_remove_audit.py +++ b/test/test_slack_cron_remove_audit.py @@ -313,6 +313,7 @@ def _make_gw(): gw._owner_id = "U000" gw.subagent_mgr = None gw._cron_injecting = {} + gw._cron_session_binding = {} gw._running_script_ids = set() gw._no_crons = False gw.cron_svc = MagicMock() diff --git a/test/test_slack_gateway.py b/test/test_slack_gateway.py index 56813327169..7842e75b2a3 100644 --- a/test/test_slack_gateway.py +++ b/test/test_slack_gateway.py @@ -144,9 +144,9 @@ async def _record(*, transports, path=None, now=None): assert await slack.send_message("C1", "notice", "1700.0") == "1701.0" live_client.ensure_channel_team.assert_awaited_once_with("C1") live_client.post_message.assert_awaited_once_with("C1", "notice", "1700.0") - assert orch.dashboard_state.channel_transports == {"teams": teams}, ( - "Slack replay must not widen the ordinary shared send registry" - ) + assert orch.dashboard_state.channel_transports == { + "teams": teams + }, "Slack replay must not widen the ordinary shared send registry" # ─── Helper utilities ──────────────────────────────────────────────────── @@ -270,11 +270,13 @@ def test_stale_allowed_users_pruned(self): cfg = KiroCrewConfig() cfg.slack.allowed_users = [{"slack_id": "U_STALE"}] with patch.object( - cfg, "load_credentials", return_value={ + cfg, + "load_credentials", + return_value={ "SLACK_APP_TOKEN": "xapp-t", "SLACK_BOT_TOKEN": "xoxb-t", "KIROCREW_OWNER_ID": "U_OWNER", - } + }, ): orch = GatewayOrchestrator(cfg) assert "U_STALE" not in orch._allowed_users @@ -342,9 +344,7 @@ def _no_backoff(self, monkeypatch): These tests assert the retry COUNT and the final result, never the delay, so the 5s this class spent asleep bought no coverage. The retry loop still runs. """ - monkeypatch.setattr( - "kiro_crew.slack.retry.asyncio.sleep", AsyncMock(return_value=None) - ) + monkeypatch.setattr("kiro_crew.slack.retry.asyncio.sleep", AsyncMock(return_value=None)) @pytest.mark.asyncio async def test_success_first_attempt(self): @@ -451,10 +451,17 @@ def test_init_services_creates_all(self): with patch("kiro_crew.slack.gateway.SessionManager"): with patch("kiro_crew.slack.gateway.HistoryConsolidator"): with patch("kiro_crew.slack.gateway.ChannelHistory"): - with patch("kiro_crew.agent.rebuild_agent_config", return_value=Path("/tmp/a")): + with patch( + "kiro_crew.agent.rebuild_agent_config", + return_value=Path("/tmp/a"), + ): with patch( "asyncio.create_subprocess_exec", - new=AsyncMock(return_value=_fake_async_proc(stdout=b"kiro-cli 1.30.0")), + new=AsyncMock( + return_value=_fake_async_proc( + stdout=b"kiro-cli 1.30.0" + ) + ), ): try: asyncio.run(orch._init_services()) @@ -492,10 +499,17 @@ def test_init_services_dashboard_only_mode(self): with patch("kiro_crew.slack.gateway.SessionManager"): with patch("kiro_crew.slack.gateway.HistoryConsolidator"): with patch("kiro_crew.slack.gateway.ChannelHistory"): - with patch("kiro_crew.agent.rebuild_agent_config", return_value=Path("/tmp/a")): + with patch( + "kiro_crew.agent.rebuild_agent_config", + return_value=Path("/tmp/a"), + ): with patch( "asyncio.create_subprocess_exec", - new=AsyncMock(return_value=_fake_async_proc(stdout=b"kiro-cli 1.30.0")), + new=AsyncMock( + return_value=_fake_async_proc( + stdout=b"kiro-cli 1.30.0" + ) + ), ): try: asyncio.run(orch._init_services()) @@ -1006,12 +1020,8 @@ class TestCheckForUpdates: async def test_no_update_available(self): orch = _make_orchestrator() orch.dashboard_state = _mock_dashboard_state() - with patch( - "kiro_crew.dashboard.handlers._do_update_check", new_callable=AsyncMock - ): - with patch( - "kiro_crew.dashboard.handlers._update_info", {"update_available": False} - ): + with patch("kiro_crew.dashboard.handlers._do_update_check", new_callable=AsyncMock): + with patch("kiro_crew.dashboard.handlers._update_info", {"update_available": False}): await orch._check_for_updates() @pytest.mark.asyncio @@ -1022,6 +1032,7 @@ async def test_update_available_no_auto(self): orch._auto_apply_update = AsyncMock() import kiro_crew.dashboard.handlers as _h from kiro_crew.platform.governance import UpdatePins + orig = _h._update_info.copy() # Create a config with auto_update=False fake_cfg = MagicMock() @@ -1168,9 +1179,7 @@ async def test_non_mainline_branch_skips(self): proc = AsyncMock() proc.communicate = AsyncMock(return_value=(b"feat/test\n", b"")) proc.returncode = 0 - with patch.dict( - "os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}, clear=False - ): + with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}, clear=False): with patch( "asyncio.create_subprocess_exec", new_callable=AsyncMock, @@ -1210,9 +1219,7 @@ def test_check_missing_deps_brazil_skips(self): orch = _make_orchestrator() with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=True - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=True): asyncio.run(orch._check_missing_deps()) # should not raise, skips pip # --- _check_console_script ------------------------------------------------- @@ -1237,7 +1244,9 @@ def test_check_console_script_skips_non_pip(self, tmp_path): orch = _make_orchestrator() with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": str(tmp_path)}, clear=False): with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): - with patch.object(gw, "sandboxed_spawn_argv_async", new_callable=AsyncMock) as spawn: + with patch.object( + gw, "sandboxed_spawn_argv_async", new_callable=AsyncMock + ) as spawn: asyncio.run(orch._check_console_script()) spawn.assert_not_awaited() @@ -1343,9 +1352,7 @@ def test_check_console_script_skipped_when_sandbox_unavailable(self, tmp_path): "sandboxed_spawn_argv_async", side_effect=unavailable, ): - with patch.object( - gw, "create_subprocess_limited", new_callable=AsyncMock - ) as spawn: + with patch.object(gw, "create_subprocess_limited", new_callable=AsyncMock) as spawn: with patch.object(gw.logger, "error") as log_error: asyncio.run(orch._check_console_script()) @@ -1576,18 +1583,225 @@ async def test_cron_callback_single_agent(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="cron result", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:j1", "run task")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:j1", "run task"), + ): result = await callback(job) assert result == "cron result" job.set_run_result.assert_called_once_with("cron result") + @pytest.mark.asyncio + async def test_cron_callback_single_agent_resets_stale_session_on_binding_change(self): + """A live persistent session is reused as-is by + SessionManager.get_or_create regardless of the cwd/agent passed to + it -- so a job whose project_path (or resolved agent) changed since + its session was last acquired must have that session explicitly + RESET first, or it silently keeps running under the OLD cwd and the + OLD agent's permissions until an idle eviction or gateway restart. + First fire (binding A) must NOT reset (nothing to reset yet); second + fire with the SAME binding must not reset either; a third fire with + a DIFFERENT binding (B) must reset before acquiring. + """ + orch = _make_orchestrator() + orch.sessions = _mock_sessions() + orch.ctx_builder = MagicMock() + orch.ctx_builder.build_message = MagicMock(return_value=("msg", None)) + orch.ctx_builder.hooks = MagicMock() + orch.subagent_mgr = MagicMock() + orch.subagent_mgr.running = [] + orch.dashboard_state = None + orch.slack = None + + with patch("kiro_crew.slack.gateway.CronService") as mock_cs: + mock_cs_inst = MagicMock() + mock_cs_inst.start = AsyncMock() + mock_cs_inst.start_reaper = MagicMock() + mock_cs_inst.register_active_session_key = MagicMock() + mock_cs_inst.clear_active_session_key = MagicMock() + mock_cs.return_value = mock_cs_inst + mock_cs.create = AsyncMock(return_value=mock_cs_inst) + await orch._init_cron() + + callback = mock_cs.create.call_args[1]["on_job"] + + job = MagicMock() + job.script = "" + job.command = "" + job.id = "jbinding" + job.name = "binding-change" + job.persistent_session = True + job.agent_sequence = [] + job.agent_id = "ea-dev" + job.channel = "" + job.created_by = "" + job.approval_mode = "auto" + job.env = None + job.acked_items = [] + job.silent = False + job.thread_ts = None + job.last_posted_hash = "" + job.consecutive_dupes = 0 + job.last_posted_at = 0.0 + job.last_failure_hash = "" + job.last_failure_at = 0.0 + job.consecutive_failures = 0 + job.project_path = "/tmp/project-a" + job.member_id = "" + job.memory_store = "" + job.model = None + + with patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="agent result", + ): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jbinding", "run"), + ): + with patch( + "kiro_crew.slack.gateway._project_path_still_canonical", + return_value=True, + ): + with patch( + "kiro_crew.slack.gateway.warm_project_agent_names", + new_callable=AsyncMock, + ): + with patch( + "kiro_crew.slack.gateway.resolve_agent_bindings", + return_value=MagicMock( + requested_resolved=True, + kiro_agent="ea-dev", + model="", + resolved_alias="", + ), + ): + # First fire: no prior binding recorded, so no reset. + result1 = await callback(job) + assert result1 == "agent result" + orch.sessions.reset.assert_not_awaited() + + # Second fire: SAME binding, still no reset. + result2 = await callback(job) + assert result2 == "agent result" + orch.sessions.reset.assert_not_awaited() + + # Third fire: project_path changed -- must reset FIRST. + job.project_path = "/tmp/project-b" + result3 = await callback(job) + assert result3 == "agent result" + orch.sessions.reset.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cron_callback_single_agent_threads_resolved_alias_as_crew_agent(self): + """A project-scoped single-agent job whose alias resolves to a + DIFFERENT kiro_agent name must have get_or_create's ``crew_agent=`` + kwarg carry ``ResolvedBindings.resolved_alias`` -- the same pattern + dashboard/chat_runner.py already uses for a live chat slot + (``crew_agent=crew_alias`` where ``crew_alias = bindings. + resolved_alias``). Without it, crew_pinned_effort/ + resolve_session_effort/rebind_watchdog all resolve against the wrong + (or no) crew identity, running a project-bound global alias under + the wrong crew-specific reasoning effort and watchdog settings. + """ + from kiro_crew.config.sections import ResolvedBindings + + orch = _make_orchestrator() + orch.sessions = _mock_sessions() + orch.ctx_builder = MagicMock() + orch.ctx_builder.build_message = MagicMock(return_value=("msg", None)) + orch.ctx_builder.hooks = MagicMock() + orch.subagent_mgr = MagicMock() + orch.subagent_mgr.running = [] + orch.dashboard_state = None + orch.slack = None + + with patch("kiro_crew.slack.gateway.CronService") as mock_cs: + mock_cs_inst = MagicMock() + mock_cs_inst.start = AsyncMock() + mock_cs_inst.start_reaper = MagicMock() + mock_cs_inst.register_active_session_key = MagicMock() + mock_cs_inst.clear_active_session_key = MagicMock() + mock_cs.return_value = mock_cs_inst + mock_cs.create = AsyncMock(return_value=mock_cs_inst) + await orch._init_cron() + + callback = mock_cs.create.call_args[1]["on_job"] + + job = MagicMock() + job.script = "" + job.command = "" + job.id = "jsingle-alias" + job.name = "single-agent-alias" + job.persistent_session = True + job.agent_sequence = [] + job.agent_id = "review-alias" + job.channel = "" + job.created_by = "" + job.approval_mode = "auto" + job.env = None + job.acked_items = [] + job.silent = False + job.thread_ts = None + job.last_posted_hash = "" + job.consecutive_dupes = 0 + job.last_posted_at = 0.0 + job.last_failure_hash = "" + job.last_failure_at = 0.0 + job.consecutive_failures = 0 + job.project_path = "/tmp/some-project" + job.member_id = "" + job.memory_store = "" + job.model = None + + resolved = ResolvedBindings( + workspace_dir=object(), + memory_store_name="default", + effective_memory_config={}, + kiro_agent="claude-opus-reviewer", + model="", + requested_resolved=True, + resolved_alias="reviewer-crew", + ) + + with patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="agent result", + ): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jsingle-alias", "run"), + ): + with patch( + "kiro_crew.slack.gateway._project_path_still_canonical", + return_value=True, + ): + with patch( + "kiro_crew.slack.gateway.warm_project_agent_names", + new_callable=AsyncMock, + ): + with patch( + "kiro_crew.slack.gateway.resolve_agent_bindings", + return_value=resolved, + ): + result = await callback(job) + + assert result == "agent result" + get_or_create_calls = orch.sessions.get_or_create.await_args_list + assert len(get_or_create_calls) == 1 + assert get_or_create_calls[0].kwargs["agent"] == "claude-opus-reviewer" + assert get_or_create_calls[0].kwargs["crew_agent"] == "reviewer-crew" + @pytest.mark.asyncio async def test_cron_callback_publishes_turn_identity(self): """Regression: the cron turn must publish session_pid_.txt. @@ -1647,6 +1861,7 @@ async def test_cron_callback_publishes_turn_identity(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None publish_events: list[str] = [] @@ -1722,6 +1937,7 @@ async def test_cron_callback_publishes_identity_per_sequence_agent(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None publish = AsyncMock() with patch("kiro_crew.slack.gateway.publish_turn_identity", publish): @@ -1812,6 +2028,7 @@ async def test_sequence_agent_reset_deferred_while_subagents_pending(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch("kiro_crew.slack.gateway.publish_turn_identity", new_callable=AsyncMock): with patch( @@ -1897,6 +2114,7 @@ async def test_sequence_agent_reset_deferred_while_subagents_queued(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch("kiro_crew.slack.gateway.publish_turn_identity", new_callable=AsyncMock): with patch( @@ -1974,6 +2192,7 @@ async def test_cron_name_is_redacted_before_delivery(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", @@ -2042,13 +2261,17 @@ async def test_cron_callback_dedup_suppresses(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="stable output", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:j2", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:j2", "run"), + ): with patch("kiro_crew.sel.sel") as mock_sel: mock_sel.return_value.log_tool_invocation = MagicMock() result = await callback(job) @@ -2106,13 +2329,17 @@ async def test_cron_callback_silent_suppresses(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="silent result", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:j3", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:j3", "run"), + ): with patch("kiro_crew.sel.sel") as mock_sel: mock_sel.return_value.log_tool_invocation = MagicMock() result = await callback(job) @@ -2190,14 +2417,20 @@ async def test_subagent_spawn_and_done_push_slots_update_debounced(self): info = SubagentInfo(id="a1", task="t", parent_session_key="dashboard:s1") # Batch: two spawns + one done inside the 0.2s window -> one push. await on_event("subagent_spawn", info, {}) - await on_event("subagent_spawn", SubagentInfo(id="a2", task="t", parent_session_key="dashboard:s1"), {}) + await on_event( + "subagent_spawn", SubagentInfo(id="a2", task="t", parent_session_key="dashboard:s1"), {} + ) await on_event("subagent_done", info, {"elapsed": 1.0}) assert orch.dashboard_state.push_slots_update.call_count == 0 # debounced, not yet flushed await asyncio.sleep(0.3) assert orch.dashboard_state.push_slots_update.call_count == 1 # A later lifecycle event schedules a fresh push. - await on_event("subagent_done", SubagentInfo(id="a2", task="t", parent_session_key="dashboard:s1"), {"elapsed": 1.0}) + await on_event( + "subagent_done", + SubagentInfo(id="a2", task="t", parent_session_key="dashboard:s1"), + {"elapsed": 1.0}, + ) await asyncio.sleep(0.3) assert orch.dashboard_state.push_slots_update.call_count == 2 @@ -2479,7 +2712,10 @@ def test_a_merged_subject_says_no_action_needed_and_a_closed_one_does_not(self): import kiro_crew.autonudge as _an from kiro_crew.monitoring.models import MonitorOutcome, MonitorState - for outcome, expect_no_action in ((MonitorOutcome.SUCCESS, True), (MonitorOutcome.BLOCKED, False)): + for outcome, expect_no_action in ( + (MonitorOutcome.SUCCESS, True), + (MonitorOutcome.BLOCKED, False), + ): loop = self._loop() # NOT capped: the cap branch outranks the terminal one, so a 24-of-24 # loop would exercise the wrong case entirely. @@ -2578,9 +2814,7 @@ def test_init_mcp_discovery_logs(self): def test_init_mcp_discovery_handles_error(self): orch = _make_orchestrator() - with patch( - "kiro_crew.mcp_discovery.list_servers", side_effect=RuntimeError("fail") - ): + with patch("kiro_crew.mcp_discovery.list_servers", side_effect=RuntimeError("fail")): orch._init_mcp_discovery() # should not raise @@ -2670,6 +2904,7 @@ async def test_cron_callback_failure_alerts(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None job.auto_paused = False job._acp_retried = False @@ -2678,7 +2913,10 @@ async def test_cron_callback_failure_alerts(self): new_callable=AsyncMock, side_effect=RuntimeError("boom"), ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jfail", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jfail", "run"), + ): with patch("kiro_crew.sel.sel") as mock_sel: mock_sel.return_value.log_tool_invocation = MagicMock() with pytest.raises(RuntimeError, match="boom"): @@ -2742,13 +2980,17 @@ async def test_cron_callback_failure_dedup_suppresses(self): job.consecutive_failures = 1 job.auto_paused = False job._acp_retried = False + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, side_effect=RuntimeError("boom"), ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jfail2", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jfail2", "run"), + ): with patch("kiro_crew.sel.sel") as mock_sel: mock_sel.return_value.log_tool_invocation = MagicMock() with pytest.raises(RuntimeError, match="boom"): @@ -2808,13 +3050,17 @@ async def test_cron_multi_agent_sequence(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="agent result", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jmulti", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jmulti", "run"), + ): result = await callback(job) assert result == "agent result" @@ -2822,6 +3068,124 @@ async def test_cron_multi_agent_sequence(self): # get_or_create called twice (once per agent) assert orch.sessions.get_or_create.await_count == 2 + @pytest.mark.asyncio + async def test_cron_multi_agent_sequence_build_message_uses_resolved_agent(self): + """A project-scoped sequence member whose alias resolves to a + DIFFERENT kiro_agent name must have build_message's ``agent=`` + kwarg carry the resolved kiro_agent, not the raw alias -- the same + mismatch already fixed on the single-agent path. build_message's own + agent lookup (_load_agent_prompt) matches a config file by + name/stem, so passing the raw alias here would build the system + prompt for the wrong (or nonexistent) agent while the session + acquired via _acquire_with_model_fallback runs under the resolved + name. + """ + from kiro_crew.config.sections import ResolvedBindings + + orch = _make_orchestrator() + orch.sessions = _mock_sessions() + orch.ctx_builder = MagicMock() + orch.ctx_builder.build_message = MagicMock(return_value=("msg", None)) + orch.ctx_builder.hooks = MagicMock() + orch.subagent_mgr = MagicMock() + orch.subagent_mgr.running = [] + orch.dashboard_state = None + orch.slack = None + + with patch("kiro_crew.slack.gateway.CronService") as mock_cs: + mock_cs_inst = MagicMock() + mock_cs_inst.start = AsyncMock() + mock_cs_inst.start_reaper = MagicMock() + mock_cs_inst.register_active_session_key = MagicMock() + mock_cs_inst.clear_active_session_key = MagicMock() + mock_cs.return_value = mock_cs_inst + mock_cs.create = AsyncMock(return_value=mock_cs_inst) + await orch._init_cron() + + callback = mock_cs.create.call_args[1]["on_job"] + + job = MagicMock() + job.script = "" + job.command = "" + job.id = "jmulti-alias" + job.name = "multi-agent-alias" + job.persistent_session = True + job.agent_sequence = ["review-alias", "agent-b"] + job.agent_id = None + job.channel = "" + job.created_by = "" + job.approval_mode = "auto" + job.env = None + job.acked_items = [] + job.silent = False + job.thread_ts = None + job.last_posted_hash = "" + job.consecutive_dupes = 0 + job.last_posted_at = 0.0 + job.last_failure_hash = "" + job.last_failure_at = 0.0 + job.consecutive_failures = 0 + job.project_path = "/tmp/some-project" + job.member_id = "" + job.memory_store = "" + job.model = None + + resolved = ResolvedBindings( + workspace_dir=object(), + memory_store_name="default", + effective_memory_config={}, + kiro_agent="claude-opus-reviewer", + model="", + requested_resolved=True, + resolved_alias="reviewer-crew", + ) + + with patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="agent result", + ): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jmulti-alias", "run"), + ): + with patch( + "kiro_crew.slack.gateway._project_path_still_canonical", + return_value=True, + ): + with patch( + "kiro_crew.slack.gateway.warm_project_agent_names", + new_callable=AsyncMock, + ): + with patch( + "kiro_crew.slack.gateway.resolve_agent_bindings", + return_value=resolved, + ): + result = await callback(job) + + assert result == "agent result" + # build_message must have been called with the RESOLVED kiro_agent + # name, not the raw "review-alias"/"agent-b" sequence-member names -- + # once per sequence member. + build_message_calls = orch.ctx_builder.build_message.call_args_list + assert len(build_message_calls) == 2 + for call in build_message_calls: + assert call.kwargs["agent"] == "claude-opus-reviewer" + # The session itself must also have been acquired with that same + # resolved name (already correct before this fix; pinned here too + # so a future regression can't silently diverge the two again). + get_or_create_calls = orch.sessions.get_or_create.await_args_list + assert len(get_or_create_calls) == 2 + for call in get_or_create_calls: + assert call.kwargs["agent"] == "claude-opus-reviewer" + # crew_agent must carry ResolvedBindings.resolved_alias -- not + # threading it would run this project-bound global alias under + # the wrong crew-specific reasoning effort and watchdog settings + # (crew_pinned_effort/resolve_session_effort/rebind_watchdog all + # key off crew_agent, same as dashboard/chat_runner.py's + # crew_agent=crew_alias pattern for a live chat slot). + assert call.kwargs["crew_agent"] == "reviewer-crew" + # ═══════════════════════════════════════════════════════════════════════════ # Tests: run_gateway entry point @@ -2839,12 +3203,8 @@ async def test_run_gateway_creates_orchestrator(self): with patch.object(cfg, "load_credentials", return_value={}): # The aggregate-cgroup-ceiling apply shells out to systemctl — # a host-service mutation the rootdir guard refuses; stub it. - with patch( - "kiro_crew.slack.gateway.ensure_agents_slice_limits", return_value=True - ): - with patch.object( - GatewayOrchestrator, "run", new_callable=AsyncMock - ) as mock_run: + with patch("kiro_crew.slack.gateway.ensure_agents_slice_limits", return_value=True): + with patch.object(GatewayOrchestrator, "run", new_callable=AsyncMock) as mock_run: await run_gateway(cfg, no_dashboard=True, no_crons=True) mock_run.assert_awaited_once() @@ -3020,24 +3380,25 @@ def _permit_update_preconditions(self): instead of the sequence it covers. A test about the resolver itself patches it again explicitly, and that inner patch wins. """ - with patch( - "kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[] - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True - ), patch( - "kiro_crew.slack.gateway.commits_ahead", return_value=0 - ), patch( - "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", - return_value="/trusted/bin/git", - ), patch( - # The interpreter-floor gate reads the pinned commit with a real - # `git show`; against a non-repo that read FAILS, and a failed read - # refuses (its own tests are in TestAutoApplyUpdateResetPath). - "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", - return_value=None, + with ( + patch("kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[]), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), + patch("kiro_crew.slack.gateway.commits_ahead", return_value=0), + patch( + "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", + return_value="/trusted/bin/git", + ), + patch( + # The interpreter-floor gate reads the pinned commit with a real + # `git show`; against a non-repo that read FAILS, and a failed read + # refuses (its own tests are in TestAutoApplyUpdateResetPath). + "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", + return_value=None, + ), ): yield @@ -3116,7 +3477,10 @@ async def test_run_raises_on_shutdown(self): with patch("kiro_crew.slack.interactions.init"): with patch("kiro_crew.slack.events.SeenCache"): with patch("kiro_crew.session.cleanup_orphaned_sessions"): - with patch("kiro_crew.dashboard.handlers._bg_mcp_probe", new_callable=AsyncMock): + with patch( + "kiro_crew.dashboard.handlers._bg_mcp_probe", + new_callable=AsyncMock, + ): with patch("os._exit"): with patch("resource.getrlimit", return_value=(256, 10240)): with patch("resource.setrlimit"): @@ -3191,9 +3555,7 @@ def recording_clear_marker(port): new_callable=AsyncMock, ): with patch("os._exit"): - with patch( - "resource.getrlimit", return_value=(256, 10240) - ): + with patch("resource.getrlimit", return_value=(256, 10240)): with patch("resource.setrlimit"): await orch.run() finally: @@ -3206,9 +3568,7 @@ def recording_clear_marker(port): assert not pid_marker.exists() @pytest.mark.asyncio - async def test_run_stalled_marker_write_does_not_block_shutdown( - self, tmp_path, monkeypatch - ): + async def test_run_stalled_marker_write_does_not_block_shutdown(self, tmp_path, monkeypatch): """A hung marker write times out; the marker is cleared and _shutdown runs. Regression: an unbounded ``await self._marker_write_task`` sat before @@ -3275,9 +3635,7 @@ def recording_clear_marker(port): new_callable=AsyncMock, ): with patch("os._exit"): - with patch( - "resource.getrlimit", return_value=(256, 10240) - ): + with patch("resource.getrlimit", return_value=(256, 10240)): with patch("resource.setrlimit"): await orch.run() finally: @@ -3355,9 +3713,7 @@ def failing_write_marker(port): new_callable=AsyncMock, ): with patch("os._exit"): - with patch( - "resource.getrlimit", return_value=(256, 10240) - ): + with patch("resource.getrlimit", return_value=(256, 10240)): with patch("resource.setrlimit"): await orch.run() finally: @@ -3458,13 +3814,17 @@ async def test_success_reminder_after_24h(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="same output", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:j_remind", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:j_remind", "run"), + ): result = await callback(job) # Should have posted (reminder path) @@ -3530,7 +3890,9 @@ async def test_dashboard_slot_idle_triggers_run_chat(self): info.elapsed = 5.0 info.started = 0.0 - with patch("kiro_crew.dashboard.chat_runner._run_chat", new_callable=AsyncMock, return_value=None): + with patch( + "kiro_crew.dashboard.chat_runner._run_chat", new_callable=AsyncMock, return_value=None + ): await on_done(info) orch.dashboard_state.notify.assert_not_called() @@ -3933,24 +4295,25 @@ def _permit_update_preconditions(self): instead of the sequence it covers. A test about the resolver itself patches it again explicitly, and that inner patch wins. """ - with patch( - "kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[] - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True - ), patch( - "kiro_crew.slack.gateway.commits_ahead", return_value=0 - ), patch( - "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", - return_value="/trusted/bin/git", - ), patch( - # The interpreter-floor gate reads the pinned commit with a real - # `git show`; against a non-repo that read FAILS, and a failed read - # refuses (its own tests are in TestAutoApplyUpdateResetPath). - "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", - return_value=None, + with ( + patch("kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[]), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), + patch("kiro_crew.slack.gateway.commits_ahead", return_value=0), + patch( + "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", + return_value="/trusted/bin/git", + ), + patch( + # The interpreter-floor gate reads the pinned commit with a real + # `git show`; against a non-repo that read FAILS, and a failed read + # refuses (its own tests are in TestAutoApplyUpdateResetPath). + "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", + return_value=None, + ), ): yield @@ -3973,7 +4336,10 @@ async def test_venv_update_full_path(self): with patch.object( GatewayOrchestrator, "_is_brazil_install", return_value=False ): - with patch("kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock): + with patch( + "kiro_crew.slack.gateway.build_frontend_async", + new_callable=AsyncMock, + ): with patch("os.execv", side_effect=OSError("test")): with patch("shutil.which", return_value=None): await orch._auto_apply_update() @@ -4375,11 +4741,15 @@ async def test_model_present_binds_embed_fn_immediately(self): orch = _make_orchestrator() orch.vector_memory = MagicMock(embed_fn=None, embed_fn_factory=None) fake_embed_fn = lambda text: [0.1] # noqa: E731 - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), \ - patch("kiro_crew.slack.gateway.make_sync_embed_fn", - return_value=fake_embed_fn) as mock_make, \ - patch("kiro_crew.slack.gateway.start_background_model_download", - return_value=None) as mock_start: + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch( + "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=fake_embed_fn + ) as mock_make, + patch( + "kiro_crew.slack.gateway.start_background_model_download", return_value=None + ) as mock_start, + ): await orch._start_embeddings() # Factory wired unconditionally (lazy rebind), fn bound immediately. assert orch.vector_memory.embed_fn_factory is mock_make @@ -4392,9 +4762,12 @@ async def test_model_absent_defers_embed_fn_and_kicks_download(self): orch = _make_orchestrator() orch.vector_memory = MagicMock(embed_fn=None, embed_fn_factory=None) fake_task = MagicMock() - with patch("kiro_crew.slack.gateway.model_file_present", return_value=False), \ - patch("kiro_crew.slack.gateway.start_background_model_download", - return_value=fake_task) as mock_start: + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=False), + patch( + "kiro_crew.slack.gateway.start_background_model_download", return_value=fake_task + ) as mock_start, + ): await orch._start_embeddings() # embed_fn stays unbound (lazy rebind picks it up once the model lands) # but the factory is wired and the background download task is stored. @@ -4429,20 +4802,22 @@ def _orch_with_store(self, *, migrated: bool): @staticmethod def _ready_embedder(): """A shared embedder whose model is loaded (wait_ready -> True).""" - return MagicMock(wait_ready=MagicMock(return_value=True), is_ready=MagicMock(return_value=True)) + return MagicMock( + wait_ready=MagicMock(return_value=True), is_ready=MagicMock(return_value=True) + ) @pytest.mark.asyncio async def test_migrates_when_not_migrated_and_legacy_present(self): orch, store = self._orch_with_store(migrated=False) set_migrated = AsyncMock() - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=True - ), patch.object( - orch, "_set_memory_migrated", set_migrated + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch( + "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() + ), + patch("kiro_crew.memory.legacy_memory_present", return_value=True), + patch.object(orch, "_set_memory_migrated", set_migrated), ): await orch._auto_migrate_memory() store.migrate_from_markdown.assert_called_once() @@ -4459,14 +4834,14 @@ async def test_migrates_when_not_migrated_and_legacy_present(self): async def test_skips_migrate_when_already_migrated(self): orch, store = self._orch_with_store(migrated=True) set_migrated = AsyncMock() - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=True - ), patch.object( - orch, "_set_memory_migrated", set_migrated + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch( + "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() + ), + patch("kiro_crew.memory.legacy_memory_present", return_value=True), + patch.object(orch, "_set_memory_migrated", set_migrated), ): await orch._auto_migrate_memory() store.migrate_from_markdown.assert_not_called() @@ -4483,14 +4858,12 @@ async def test_sweep_deferred_when_model_not_ready(self): not_ready = MagicMock( wait_ready=MagicMock(return_value=False), is_ready=MagicMock(return_value=False) ) - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.slack.gateway.get_shared_embedder", return_value=not_ready - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=True - ), patch.object( - orch, "_set_memory_migrated", AsyncMock() + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch("kiro_crew.slack.gateway.get_shared_embedder", return_value=not_ready), + patch("kiro_crew.memory.legacy_memory_present", return_value=True), + patch.object(orch, "_set_memory_migrated", AsyncMock()), ): await orch._auto_migrate_memory() not_ready.wait_ready.assert_called_once() @@ -4500,12 +4873,11 @@ async def test_sweep_deferred_when_model_not_ready(self): async def test_fresh_install_no_legacy_still_flips_migrated(self): orch, store = self._orch_with_store(migrated=False) set_migrated = AsyncMock() - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=False - ), patch.object( - orch, "_set_memory_migrated", set_migrated + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch("kiro_crew.memory.legacy_memory_present", return_value=False), + patch.object(orch, "_set_memory_migrated", set_migrated), ): await orch._auto_migrate_memory() # No legacy → don't parse markdown, but still flip the flag + ack (0 counts). @@ -4521,17 +4893,17 @@ async def test_model_absent_awaits_download_then_sweeps(self): # Model absent at migrate time, present after the download task resolves. presence = iter([False, False, True, True]) orch._model_download_task = asyncio.ensure_future(asyncio.sleep(0)) - with patch( - "kiro_crew.slack.gateway.model_file_present", - side_effect=lambda: next(presence, True), - ), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=True - ), patch.object( - orch, "_set_memory_migrated", AsyncMock() + with ( + patch( + "kiro_crew.slack.gateway.model_file_present", + side_effect=lambda: next(presence, True), + ), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch( + "kiro_crew.slack.gateway.get_shared_embedder", return_value=self._ready_embedder() + ), + patch("kiro_crew.memory.legacy_memory_present", return_value=True), + patch.object(orch, "_set_memory_migrated", AsyncMock()), ): await orch._auto_migrate_memory() # Migrated even though the model was absent; sweep ran after the wait. @@ -4543,12 +4915,11 @@ async def test_migrate_error_leaves_flag_false_and_survives(self): orch, store = self._orch_with_store(migrated=False) store.migrate_from_markdown.side_effect = RuntimeError("boom") set_migrated = AsyncMock() - with patch("kiro_crew.slack.gateway.model_file_present", return_value=True), patch( - "kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1]) - ), patch( - "kiro_crew.memory.legacy_memory_present", return_value=True - ), patch.object( - orch, "_set_memory_migrated", set_migrated + with ( + patch("kiro_crew.slack.gateway.model_file_present", return_value=True), + patch("kiro_crew.slack.gateway.make_sync_embed_fn", return_value=(lambda t: [0.1])), + patch("kiro_crew.memory.legacy_memory_present", return_value=True), + patch.object(orch, "_set_memory_migrated", set_migrated), ): # Must not raise — boot survives. await orch._auto_migrate_memory() @@ -4761,24 +5132,25 @@ def _permit_update_preconditions(self): instead of the sequence it covers. A test about the resolver itself patches it again explicitly, and that inner patch wins. """ - with patch( - "kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[] - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True - ), patch( - "kiro_crew.slack.gateway.commits_ahead", return_value=0 - ), patch( - "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", - return_value="/trusted/bin/git", - ), patch( - # The interpreter-floor gate reads the pinned commit with a real - # `git show`; against a non-repo that read FAILS, and a failed read - # refuses (its own tests are in TestAutoApplyUpdateResetPath). - "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", - return_value=None, + with ( + patch("kiro_crew.slack.gateway.hidden_worktree_edits", return_value=[]), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), + patch("kiro_crew.slack.gateway.commits_ahead", return_value=0), + patch( + "kiro_crew.slack.gateway.platform_compat.trusted_git_bin", + return_value="/trusted/bin/git", + ), + patch( + # The interpreter-floor gate reads the pinned commit with a real + # `git show`; against a non-repo that read FAILS, and a failed read + # refuses (its own tests are in TestAutoApplyUpdateResetPath). + "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", + return_value=None, + ), ): yield @@ -4794,10 +5166,14 @@ async def test_a_floor_refusal_is_redacted_and_capped_before_it_is_pushed(self, orch.dashboard_state = ds secret = "https://evil.example/leak?token=AKIA" + "X" * 40 breach = "the incoming revision requires Python >=3.12 (" + secret + ") " + "p" * 900 - with patch.dict(os.environ, {"KIROCREW_PROJECT_DIR": str(tmp_path)}), patch( - "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", - return_value=breach, - ), patch("asyncio.create_subprocess_exec", side_effect=self._scripted_git): + with ( + patch.dict(os.environ, {"KIROCREW_PROJECT_DIR": str(tmp_path)}), + patch( + "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", + return_value=breach, + ), + patch("asyncio.create_subprocess_exec", side_effect=self._scripted_git), + ): await orch._auto_apply_update() pushed = [c.args for c in ds.push_update_progress.call_args_list if c.args[0] == "failed"] assert len(pushed) == 1 @@ -4814,10 +5190,14 @@ async def test_a_floor_git_cannot_read_refuses_before_the_reset(self, tmp_path): orch = _make_orchestrator() ds = _mock_dashboard_state() orch.dashboard_state = ds - with patch.dict(os.environ, {"KIROCREW_PROJECT_DIR": str(tmp_path)}), patch( - "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", - side_effect=gw.dep_sync.IncomingFloorUnreadable("git show timed out"), - ), patch("asyncio.create_subprocess_exec", side_effect=self._scripted_git): + with ( + patch.dict(os.environ, {"KIROCREW_PROJECT_DIR": str(tmp_path)}), + patch( + "kiro_crew.slack.gateway.dep_sync.incoming_python_floor_breach", + side_effect=gw.dep_sync.IncomingFloorUnreadable("git show timed out"), + ), + patch("asyncio.create_subprocess_exec", side_effect=self._scripted_git), + ): await orch._auto_apply_update() pushed = [c.args for c in ds.push_update_progress.call_args_list if c.args[0] == "failed"] assert len(pushed) == 1 and "could not read" in pushed[0][1] @@ -4916,9 +5296,7 @@ async def test_uncommitted_tracked_changes_refuse_the_reset(self): await orch._auto_apply_update() # The destructive step never ran. - assert not any( - "reset" in [str(a) for a in args] for args in spawned - ), spawned + assert not any("reset" in [str(a) for a in args] for args in spawned), spawned # And nothing downstream of it ran either. mock_build.assert_not_awaited() mock_execv.assert_not_called() @@ -5017,9 +5395,7 @@ async def test_every_git_step_runs_the_resolved_binary(self): await orch._auto_apply_update() git_calls = [ - [str(a) for a in args] - for args in spawned - if args and str(args[0]).endswith("git") + [str(a) for a in args] for args in spawned if args and str(args[0]).endswith("git") ] assert git_calls, spawned for argv in git_calls: @@ -5070,9 +5446,7 @@ async def test_an_unknown_hidden_edit_state_refuses(self): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}): with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): - with patch( - "kiro_crew.slack.gateway.hidden_worktree_edits", return_value=None - ): + with patch("kiro_crew.slack.gateway.hidden_worktree_edits", return_value=None): with patch("os.execv") as mock_execv: with patch("shutil.which", return_value=None): await orch._auto_apply_update() @@ -5462,9 +5836,7 @@ async def test_an_unlistable_added_set_refuses(self, tmp_path): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": str(tmp_path)}): with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): - with patch( - "kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock - ): + with patch("kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock): with patch("os.execv") as mock_execv: with patch("shutil.which", return_value=None): await orch._auto_apply_update() @@ -5489,10 +5861,8 @@ async def test_an_unreadable_status_refuses(self): _fake_exec = _git_exec_fake(status_rc=1, record=spawned) with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}): - with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): - with patch( - "kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock - ): + with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): + with patch("kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock): with patch("os.execv") as mock_execv: with patch("shutil.which", return_value=None): await orch._auto_apply_update() @@ -5522,9 +5892,7 @@ async def test_a_refusal_skips_the_core_dep_repair_entirely(self): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}): with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): - with patch( - "kiro_crew.dep_sync.sync_or_reinstall", return_value=dep_sync.REFUSED - ): + with patch("kiro_crew.dep_sync.sync_or_reinstall", return_value=dep_sync.REFUSED): with patch( "kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock, @@ -5562,9 +5930,7 @@ async def test_reset_target_is_resolved_through_the_full_remote_ref(self): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}): with patch("asyncio.create_subprocess_exec", side_effect=_fake_exec): - with patch( - "kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock - ): + with patch("kiro_crew.slack.gateway.build_frontend_async", new_callable=AsyncMock): with patch("os.execv"): with patch("shutil.which", return_value=None): await orch._auto_apply_update() @@ -5576,9 +5942,7 @@ async def test_reset_target_is_resolved_through_the_full_remote_ref(self): and "--abbrev-ref" not in [str(a) for a in args] ] assert revparses, spawned - assert any("refs/remotes/origin/main^{commit}" in argv for argv in revparses), ( - revparses - ) + assert any("refs/remotes/origin/main^{commit}" in argv for argv in revparses), revparses # The bare form must not be what git is asked to resolve. assert not any("origin/main^{commit}" in argv for argv in revparses), revparses @@ -5784,6 +6148,7 @@ async def test_acp_retry_on_process_death(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None job._acp_retried = False from kiro_crew.acp.client import AcpError @@ -5797,7 +6162,10 @@ async def _fake_stream(*args, **kwargs): return "retry success" with patch("kiro_crew.slack.gateway.stream_and_collect", side_effect=_fake_stream): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jacp", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jacp", "run"), + ): result = await callback(job) assert result == "retry success" @@ -6259,13 +6627,17 @@ async def test_acked_items_appended_to_message(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="acked result", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jack", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jack", "run"), + ): with patch("kiro_crew.sel.sel") as mock_sel: mock_sel.return_value.log_tool_invocation = MagicMock() result = await callback(job) @@ -6434,9 +6806,14 @@ async def test_run_with_ollama_config(self): with patch("kiro_crew.slack.interactions.init"): with patch("kiro_crew.slack.events.SeenCache"): with patch("kiro_crew.session.cleanup_orphaned_sessions"): - with patch("kiro_crew.dashboard.handlers._bg_mcp_probe", new_callable=AsyncMock): + with patch( + "kiro_crew.dashboard.handlers._bg_mcp_probe", + new_callable=AsyncMock, + ): with patch("os._exit"): - with patch("resource.getrlimit", return_value=(256, 10240)): + with patch( + "resource.getrlimit", return_value=(256, 10240) + ): with patch("resource.setrlimit"): await orch.run() finally: @@ -6482,6 +6859,7 @@ async def _init_dash(): orch._local_only = True orch._configured_host = None orch._dashboard_port = 6779 + orch._init_dashboard = _init_dash fresh_event = asyncio.Event() @@ -6490,19 +6868,32 @@ async def _init_dash(): with patch.object(loop, "add_signal_handler"): with patch("kiro_crew.shutdown_event", fresh_event): with patch("kiro_crew.slack.gateway.shutdown_event", fresh_event): - with patch("kiro_crew.slack.gateway.resolve_dashboard_host", - return_value="127.0.0.1"): - with patch("kiro_crew.slack.gateway.build_dashboard_url", - return_value="http://127.0.0.1:6779/?t=tok"): - with patch("kiro_crew.slack.gateway.format_dashboard_urls", - return_value=["url-line-1", "url-line-2"]): + with patch( + "kiro_crew.slack.gateway.resolve_dashboard_host", return_value="127.0.0.1" + ): + with patch( + "kiro_crew.slack.gateway.build_dashboard_url", + return_value="http://127.0.0.1:6779/?t=tok", + ): + with patch( + "kiro_crew.slack.gateway.format_dashboard_urls", + return_value=["url-line-1", "url-line-2"], + ): with patch("kiro_crew.slack.events.init_socket_mode"): with patch("kiro_crew.slack.interactions.init"): with patch("kiro_crew.slack.events.SeenCache"): - with patch("kiro_crew.session.cleanup_orphaned_sessions"): - with patch("kiro_crew.dashboard.handlers._bg_mcp_probe", new_callable=AsyncMock): + with patch( + "kiro_crew.session.cleanup_orphaned_sessions" + ): + with patch( + "kiro_crew.dashboard.handlers._bg_mcp_probe", + new_callable=AsyncMock, + ): with patch("os._exit"): - with patch("resource.getrlimit", return_value=(256, 10240)): + with patch( + "resource.getrlimit", + return_value=(256, 10240), + ): with patch("resource.setrlimit"): await orch.run() # Let bg_session task drain @@ -6545,6 +6936,7 @@ async def _init_dash(): orch._local_only = True orch._configured_host = None orch._dashboard_port = 6779 + orch._init_dashboard = _init_dash # One ordered trace of both events. The URL lines are a distinctive @@ -6566,17 +6958,24 @@ async def _tracing_probe(): with patch.object(loop, "add_signal_handler"): with patch("kiro_crew.shutdown_event", fresh_event): with patch("kiro_crew.slack.gateway.shutdown_event", fresh_event): - with patch("kiro_crew.slack.gateway.resolve_dashboard_host", - return_value="127.0.0.1"): - with patch("kiro_crew.slack.gateway.build_dashboard_url", - return_value="http://127.0.0.1:6779/?t=tok"): - with patch("kiro_crew.slack.gateway.format_dashboard_urls", - return_value=["url-line-1", "url-line-2"]): + with patch( + "kiro_crew.slack.gateway.resolve_dashboard_host", return_value="127.0.0.1" + ): + with patch( + "kiro_crew.slack.gateway.build_dashboard_url", + return_value="http://127.0.0.1:6779/?t=tok", + ): + with patch( + "kiro_crew.slack.gateway.format_dashboard_urls", + return_value=["url-line-1", "url-line-2"], + ): with patch("builtins.print", _tracing_print): with patch("kiro_crew.slack.events.init_socket_mode"): with patch("kiro_crew.slack.interactions.init"): with patch("kiro_crew.slack.events.SeenCache"): - with patch("kiro_crew.session.cleanup_orphaned_sessions"): + with patch( + "kiro_crew.session.cleanup_orphaned_sessions" + ): with patch( "kiro_crew.dashboard.handlers._bg_mcp_probe", _tracing_probe, @@ -6628,6 +7027,7 @@ async def _init_dash(): orch._local_only = True orch._configured_host = None orch._dashboard_port = 6779 + orch._init_dashboard = _init_dash fresh_event = asyncio.Event() @@ -6636,8 +7036,9 @@ async def _init_dash(): with patch.object(loop, "add_signal_handler"): with patch("kiro_crew.shutdown_event", fresh_event): with patch("kiro_crew.slack.gateway.shutdown_event", fresh_event): - with patch("kiro_crew.slack.gateway.resolve_dashboard_host", - return_value="127.0.0.1"): + with patch( + "kiro_crew.slack.gateway.resolve_dashboard_host", return_value="127.0.0.1" + ): with patch( "kiro_crew.slack.gateway.format_dashboard_urls", side_effect=RuntimeError("cannot format URL"), @@ -6672,9 +7073,7 @@ def test_pip_install_on_missing_dep(self): orch = _make_orchestrator() with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=False - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): mock_exec = AsyncMock(return_value=_fake_async_proc(returncode=0)) with patch("asyncio.create_subprocess_exec", mock_exec): asyncio.run(orch._check_missing_deps()) @@ -6690,9 +7089,7 @@ def test_pip_install_failure(self): orch = _make_orchestrator() with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=False - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): mock_exec = AsyncMock( return_value=_fake_async_proc(returncode=1, stderr=b"error") ) @@ -6715,12 +7112,8 @@ async def _communicate(): proc.communicate = MagicMock(side_effect=_communicate) with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=False - ): - with patch.object( - GatewayOrchestrator, "_DEP_INSTALL_TIMEOUT_SECS", 0.05 - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): + with patch.object(GatewayOrchestrator, "_DEP_INSTALL_TIMEOUT_SECS", 0.05): with patch( "asyncio.create_subprocess_exec", AsyncMock(return_value=proc), @@ -6776,12 +7169,8 @@ async def _communicate(): orch = _make_orchestrator() with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=False - ): - with patch( - "asyncio.create_subprocess_exec", AsyncMock(return_value=proc) - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)): task = asyncio.create_task(orch._check_missing_deps()) await asyncio.sleep(0.05) # let it reach the await task.cancel() @@ -6912,12 +7301,8 @@ async def _communicate(): with patch("importlib.util.find_spec", return_value=None): with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/proj"}): - with patch.object( - GatewayOrchestrator, "_is_brazil_install", return_value=False - ): - with patch( - "asyncio.create_subprocess_exec", side_effect=_async_exec - ): + with patch.object(GatewayOrchestrator, "_is_brazil_install", return_value=False): + with patch("asyncio.create_subprocess_exec", side_effect=_async_exec): with patch( "subprocess.run", side_effect=_blocking_run, @@ -6927,9 +7312,7 @@ async def _communicate(): # Above every probe deadline so a regressed # run fails on the starvation assert below, # not on a torn-down timeout. - await asyncio.wait_for( - orch._check_missing_deps(), timeout=60 - ) + await asyncio.wait_for(orch._check_missing_deps(), timeout=60) finally: ticker_task.cancel() assert state["starved"] == [], ( @@ -6954,9 +7337,7 @@ async def test_slow_fts_rebuild_does_not_starve_heartbeat(self): side_effect=self._make_probe(state, "rebuild-index", result=3) ) mock_vm_inst = MagicMock() - mock_vm_inst.init = MagicMock( - side_effect=self._make_probe(state, "vector-init") - ) + mock_vm_inst.init = MagicMock(side_effect=self._make_probe(state, "vector-init")) with patch("kiro_crew.slack.gateway.MemoryStore", return_value=mock_mem_inst): with patch("kiro_crew.vector_memory.VectorMemoryStore") as mock_vm: mock_vm.return_value = mock_vm_inst @@ -6964,14 +7345,24 @@ async def test_slow_fts_rebuild_does_not_starve_heartbeat(self): with patch("kiro_crew.slack.gateway.HookManager"): with patch("kiro_crew.slack.gateway.LessonStore"): with patch("kiro_crew.slack.gateway.ContextBuilder"): - with patch("kiro_crew.slack.gateway.ConversationLog", return_value=MagicMock()): + with patch( + "kiro_crew.slack.gateway.ConversationLog", + return_value=MagicMock(), + ): with patch("kiro_crew.slack.gateway.SessionManager"): with patch("kiro_crew.slack.gateway.HistoryConsolidator"): with patch("kiro_crew.slack.gateway.ChannelHistory"): - with patch("kiro_crew.agent.rebuild_agent_config", return_value=Path("/tmp/a")): + with patch( + "kiro_crew.agent.rebuild_agent_config", + return_value=Path("/tmp/a"), + ): with patch( "asyncio.create_subprocess_exec", - new=AsyncMock(return_value=_fake_async_proc(stdout=b"kiro-cli 1.30.0")), + new=AsyncMock( + return_value=_fake_async_proc( + stdout=b"kiro-cli 1.30.0" + ) + ), ): ticker_task = asyncio.create_task(_ticker()) try: @@ -6985,14 +7376,24 @@ async def test_slow_fts_rebuild_does_not_starve_heartbeat(self): assert state["probed"] == [] orch.ctx_builder.memory = mock_mem_inst with ( - patch("kiro_crew.context.reset_memory_caches"), - patch("kiro_crew.memory_backup.apply_pending_member_restores", return_value={}), + patch( + "kiro_crew.context.reset_memory_caches" + ), + patch( + "kiro_crew.memory_backup.apply_pending_member_restores", + return_value={}, + ), ): assert await asyncio.wait_for( - asyncio.to_thread(orch._initialize_memory_worker), timeout=90 + asyncio.to_thread( + orch._initialize_memory_worker + ), + timeout=90, ) finally: - await asyncio.to_thread(orch._stop_memory_startup) + await asyncio.to_thread( + orch._stop_memory_startup + ) ticker_task.cancel() assert state["starved"] == [], ( f"loop starved during service init: {state['starved']} observed no " @@ -7062,13 +7463,17 @@ async def test_slack_delivery_exception_notifies_dashboard(self): job.last_failure_hash = "" job.last_failure_at = 0.0 job.consecutive_failures = 0 + job.project_path = None with patch( "kiro_crew.slack.gateway.stream_and_collect", new_callable=AsyncMock, return_value="result", ): - with patch("kiro_crew.slack.gateway.build_cron_session_context", return_value=("cron:jslack", "run")): + with patch( + "kiro_crew.slack.gateway.build_cron_session_context", + return_value=("cron:jslack", "run"), + ): result = await callback(job) assert result == "result" @@ -7145,9 +7550,7 @@ async def test_renders_options_action_block(self): orch.sessions.get_channel = MagicMock(return_value="C123") orch.sessions.get_thread = MagicMock(return_value="T456") - posted = await orch._deliver_cron_response( - "cron:job1", "pick one\n\n[OPTIONS: Yes | No]" - ) + posted = await orch._deliver_cron_response("cron:job1", "pick one\n\n[OPTIONS: Yes | No]") assert posted is True body = slack.post_message.call_args.args[1] @@ -7266,14 +7669,14 @@ async def test_slack_subagent_persists_to_conversation_log(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info() - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized response", - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized response", + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), ): await on_done(info) @@ -7300,14 +7703,14 @@ async def test_slack_subagent_redacts_response_before_persist(self): # Response carries a credential-shaped token that must not reach disk raw. leaked = "result aws_secret_access_key=AKIAIOSFODNN7EXAMPLE done" - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value=leaked, - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value=leaked, + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), ): await on_done(info) @@ -7323,14 +7726,14 @@ async def test_slack_subagent_skips_persistence_for_temporary_thread(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info() - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="response", - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=True - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="response", + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=True), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), ): await on_done(info) @@ -7343,14 +7746,14 @@ async def test_slack_subagent_skips_persistence_for_incognito_thread(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info() - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="response", - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=True + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="response", + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=True), ): await on_done(info) @@ -7364,14 +7767,14 @@ async def test_slack_subagent_persistence_failure_does_not_break_flow(self): info = self._make_info() orch.conv_log.append = MagicMock(side_effect=OSError("disk full")) - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="response", - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="response", + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), ): # Should not raise await on_done(info) @@ -7408,14 +7811,14 @@ async def test_slack_subagent_persists_even_when_slack_post_fails(self): # Slack delivery fails (best-effort), but injection already succeeded. orch.slack.post_message = AsyncMock(side_effect=RuntimeError("slack down")) - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized response", - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized response", + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), ): # Must not raise despite the Slack failure. await on_done(info) @@ -7434,16 +7837,15 @@ async def test_slack_subagent_persists_exactly_once_after_timeout_retry(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info() - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - side_effect=[asyncio.TimeoutError(), "response text"], - ), patch( - "kiro_crew.slack.gateway.is_thread_temporary", return_value=False - ), patch( - "kiro_crew.slack.gateway.is_thread_incognito", return_value=False - ), patch( - "asyncio.sleep", new_callable=AsyncMock + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + side_effect=[asyncio.TimeoutError(), "response text"], + ), + patch("kiro_crew.slack.gateway.is_thread_temporary", return_value=False), + patch("kiro_crew.slack.gateway.is_thread_incognito", return_value=False), + patch("asyncio.sleep", new_callable=AsyncMock), ): await on_done(info) @@ -7551,11 +7953,14 @@ async def test_telegram_parent_reply_reaches_registered_transport(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:direct:12345") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_awaited_once_with( @@ -7578,11 +7983,14 @@ async def test_origin_link_wins_over_stored_channel(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("discord:kirocrew:direct:U999") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_awaited_once_with("C777", "reply", thread_id=None) @@ -7614,11 +8022,14 @@ async def test_missing_transport_degrades_to_notification_only(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:direct:12345") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized reply", + ), + self._permit_governance(), + ): await on_done(info) orch.slack.post_message.assert_not_awaited() @@ -7633,11 +8044,14 @@ async def test_transport_send_failure_never_raises(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:direct:12345") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized reply", + ), + self._permit_governance(), + ): await on_done(info) # must not raise orch.dashboard_state.notify.assert_called() @@ -7654,22 +8068,23 @@ async def test_target_snapshotted_before_injection_survives_session_reset(self): # Origin link present at entry, gone after the first (timed-out) # injection attempt — exactly what reset() does to a live session. orch.sessions.get_origin_link = MagicMock( - side_effect=[ChannelLink("discord", channel_id="C777", thread_id="T1")] - + [None] * 8 + side_effect=[ChannelLink("discord", channel_id="C777", thread_id="T1")] + [None] * 8 ) on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("discord:kirocrew:direct:U999") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - side_effect=[asyncio.TimeoutError, "reply after retry"], - ), patch("asyncio.sleep", new_callable=AsyncMock), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + side_effect=[asyncio.TimeoutError, "reply after retry"], + ), + patch("asyncio.sleep", new_callable=AsyncMock), + self._permit_governance(), + ): await on_done(info) - transport.send_message.assert_awaited_once_with( - "C777", "reply after retry", thread_id="T1" - ) + transport.send_message.assert_awaited_once_with("C777", "reply after retry", thread_id="T1") @pytest.mark.asyncio async def test_peer_resolution_outcome_is_sel_audited(self): @@ -7682,11 +8097,15 @@ async def test_peer_resolution_outcome_is_sel_audited(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:direct:12345") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(), patch("kiro_crew.slack.gateway.sel") as mock_sel: + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + patch("kiro_crew.slack.gateway.sel") as mock_sel, + ): mock_sel.return_value.log_api_access = MagicMock() await on_done(info) @@ -7707,11 +8126,14 @@ async def test_reply_is_redacted_before_send(self): info = self._make_info("telegram:kirocrew:direct:12345") leaked = "result aws_secret_access_key=AKIAIOSFODNN7EXAMPLE done" - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value=leaked, - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value=leaked, + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_awaited_once() @@ -7728,11 +8150,14 @@ async def test_forum_parent_without_links_degrades_to_notification(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:forum:987:5") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="synthesized reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="synthesized reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_not_awaited() @@ -7752,11 +8177,14 @@ async def test_mirror_link_delivers_into_forum_topic(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:forum:987:5") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_awaited_once_with("987", "reply", thread_id="5") @@ -7772,11 +8200,14 @@ async def test_unified_parent_uses_stored_channel_value(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("unified:kirocrew") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_awaited_once_with("12345", "reply", thread_id=None) @@ -7792,11 +8223,14 @@ async def test_stored_peer_id_is_resolved_to_a_postable_conversation(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("discord:kirocrew:direct:U999") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + ): await on_done(info) transport.resolve_configured_target.assert_awaited_once_with("user:U999") @@ -7808,17 +8242,18 @@ async def test_unreachable_peer_fails_closed(self): conversation/serviceUrl) degrades to notification-only, no send.""" transport = self._fake_transport("teams") transport.resolve_configured_target = AsyncMock(return_value=None) - orch, mock_sm = self._setup( - parent_channel="teams:user@example.com", transport=transport - ) + orch, mock_sm = self._setup(parent_channel="teams:user@example.com", transport=transport) on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("teams:kirocrew:direct:user@example.com") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + self._permit_governance(), + ): await on_done(info) transport.send_message.assert_not_awaited() @@ -7832,13 +8267,16 @@ async def test_governance_denial_blocks_the_send(self): on_done = mock_sm.call_args[1]["on_done"] info = self._make_info("telegram:kirocrew:direct:12345") - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value="reply", - ), patch( - "kiro_crew.platform.governance_profiles.vet_and_audit", - return_value=SimpleNamespace(permitted=False), + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value="reply", + ), + patch( + "kiro_crew.platform.governance_profiles.vet_and_audit", + return_value=SimpleNamespace(permitted=False), + ), ): await on_done(info) @@ -7854,11 +8292,14 @@ async def test_long_reply_is_chunked_to_the_transport_limit(self): info = self._make_info("telegram:kirocrew:direct:12345") long_reply = "\n".join(f"line {i} of the reply" for i in range(6)) - with patch( - "kiro_crew.slack.gateway.stream_and_collect", - new_callable=AsyncMock, - return_value=long_reply, - ), self._permit_governance(): + with ( + patch( + "kiro_crew.slack.gateway.stream_and_collect", + new_callable=AsyncMock, + return_value=long_reply, + ), + self._permit_governance(), + ): await on_done(info) assert transport.send_message.await_count > 1 @@ -8102,9 +8543,7 @@ async def test_restart_fences_then_closes_and_final_drains(self, monkeypatch): orch = _make_orchestrator() orch.dashboard_state = None sessions = SimpleNamespace(inbound_callback_count=0) - sessions.fence_update_restart = MagicMock( - side_effect=lambda: order.append("fence") or True - ) + sessions.fence_update_restart = MagicMock(side_effect=lambda: order.append("fence") or True) sessions.close_all = AsyncMock(side_effect=lambda: order.append("close")) orch.sessions = sessions @@ -8146,9 +8585,7 @@ def _orch_with(self, **sections): for key, value in values.items(): object.__setattr__(section, key, value) orch._cfg.load_credentials = lambda: {} - boot = tuple( - ChannelDescriptor(channel_type=name, start=AsyncMock()) for name in sections - ) + boot = tuple(ChannelDescriptor(channel_type=name, start=AsyncMock()) for name in sections) return orch, boot def test_an_enabled_channel_missing_its_token_is_badged_with_the_reason(self): @@ -8237,8 +8674,7 @@ def _patch_starts(self, stack, *, discord_ret=None): "webex": AsyncMock(), } self._descriptors = tuple( - ChannelDescriptor(channel_type=name, start=mock) - for name, mock in mocks.items() + ChannelDescriptor(channel_type=name, start=mock) for name, mock in mocks.items() ) return mocks @@ -8435,9 +8871,7 @@ async def test_provider_raising_does_not_run_legacy(self, monkeypatch): orch.dashboard_state = _mock_dashboard_state() provider = CommandProvider(check_command="c", apply_command="a") - monkeypatch.setattr( - "kiro_crew.platform.update_provider.resolve_provider", lambda: provider - ) + monkeypatch.setattr("kiro_crew.platform.update_provider.resolve_provider", lambda: provider) boom = AsyncMock(side_effect=RuntimeError("provider exploded")) monkeypatch.setattr(orch, "_check_for_updates_via_provider", boom) legacy = AsyncMock() @@ -8459,9 +8893,7 @@ async def test_resolution_failure_still_uses_legacy(self, monkeypatch): def _boom(): raise RuntimeError("policy unreadable") - monkeypatch.setattr( - "kiro_crew.platform.update_provider.resolve_provider", _boom - ) + monkeypatch.setattr("kiro_crew.platform.update_provider.resolve_provider", _boom) legacy = AsyncMock() monkeypatch.setattr(orch, "_check_for_updates_legacy", legacy) @@ -8492,9 +8924,7 @@ async def test_unsafe_base_refuses_before_spawning(self, monkeypatch): } } ) - monkeypatch.setattr( - "kiro_crew.platform.update_layout.cdn_bases_are_safe", lambda: False - ) + monkeypatch.setattr("kiro_crew.platform.update_layout.cdn_bases_are_safe", lambda: False) spawn = AsyncMock() monkeypatch.setattr("asyncio.create_subprocess_exec", spawn) @@ -8538,9 +8968,7 @@ async def test_the_installer_is_spawned_from_the_remediation_command(self, monke # command SOURCE, which is platform-independent; the refusals themselves # are pinned by the two tests below. monkeypatch.setattr("kiro_crew.slack.gateway.sys.platform", "linux") - monkeypatch.setattr( - "kiro_crew.platform_compat.trusted_system_bin", lambda name: "/bin/sh" - ) + monkeypatch.setattr("kiro_crew.platform_compat.trusted_system_bin", lambda name: "/bin/sh") monkeypatch.setattr( "kiro_crew.platform.update_provider._trusted_path_env", lambda: {"PATH": "/usr/bin:/bin"}, @@ -8632,6 +9060,7 @@ async def test_no_command_in_the_capability_does_not_spawn(self, monkeypatch): await orch._auto_apply_wheel_update() spawn.assert_not_awaited() + """The SSE snapshot renders the update badge from _update_info["available"], which only the legacy check writes. A provider carries its own result, so notifying without publishing it left the badge reading a stale False and the @@ -9034,9 +9463,7 @@ def test_every_rostered_channel_is_accounted_for(self) -> None: from kiro_crew.channels import builtin_channel_descriptors rostered = {descriptor.channel_type for descriptor in builtin_channel_descriptors()} - accounted = set(_uncredentialed_probe_operands()) | set( - _UNCREDENTIALED_PROBE_EXEMPTIONS - ) + accounted = set(_uncredentialed_probe_operands()) | set(_UNCREDENTIALED_PROBE_EXEMPTIONS) assert rostered == accounted, ( "rostered channels must have an uncredentialed probe row or an " "explicit config-only/token-driven exemption; " @@ -9061,9 +9488,7 @@ def test_each_probe_tracks_the_predicate_operands(self) -> None: ) def test_exemptions_are_not_credential_probe_rows(self) -> None: - overlap = set(_uncredentialed_probe_operands()) & set( - _UNCREDENTIALED_PROBE_EXEMPTIONS - ) + overlap = set(_uncredentialed_probe_operands()) & set(_UNCREDENTIALED_PROBE_EXEMPTIONS) assert not overlap, ( "a channel cannot be both credential-probed and exempt: " f"{sorted(overlap)}" ) @@ -9341,9 +9766,7 @@ async def test_a_fully_credentialed_channel_is_silent( assert self._channel_records(caplog) == [] @pytest.mark.asyncio - async def test_teams_tenant_id_is_not_a_credential_operand( - self, caplog, monkeypatch - ) -> None: + async def test_teams_tenant_id_is_not_a_credential_operand(self, caplog, monkeypatch) -> None: # The trap the table must not fall into: the tenant id sits in config # right next to the two operands that count, but _teams_enabled never # reads it — app id + password present with NO tenant is fully @@ -9415,15 +9838,15 @@ async def _fake_exec(*args, **kwargs): proc.wait = AsyncMock(return_value=0) return proc - with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), patch( - "asyncio.create_subprocess_exec", side_effect=_fake_exec - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="repository declares filter.evil.smudge", - ), patch( - "kiro_crew.slack.gateway.is_primary_branch", return_value=True - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True + with ( + patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), + patch("asyncio.create_subprocess_exec", side_effect=_fake_exec), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="repository declares filter.evil.smudge", + ), + patch("kiro_crew.slack.gateway.is_primary_branch", return_value=True), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), ): await orch._auto_apply_update() @@ -9455,15 +9878,15 @@ async def _fake_exec(*args, **kwargs): proc.wait = AsyncMock(return_value=proc.returncode) return proc - with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), patch( - "asyncio.create_subprocess_exec", side_effect=_fake_exec - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True - ), patch( - "kiro_crew.slack.gateway.commits_ahead", return_value=2 + with ( + patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), + patch("asyncio.create_subprocess_exec", side_effect=_fake_exec), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), + patch("kiro_crew.slack.gateway.commits_ahead", return_value=2), ): await orch._auto_apply_update() @@ -9486,15 +9909,15 @@ async def _fake_exec(*args, **kwargs): proc.wait = AsyncMock(return_value=proc.returncode) return proc - with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), patch( - "asyncio.create_subprocess_exec", side_effect=_fake_exec - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=True - ), patch( - "kiro_crew.slack.gateway.commits_ahead", return_value=None + with ( + patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), + patch("asyncio.create_subprocess_exec", side_effect=_fake_exec), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=True), + patch("kiro_crew.slack.gateway.commits_ahead", return_value=None), ): await orch._auto_apply_update() @@ -9522,13 +9945,14 @@ async def _fake_exec(*args, **kwargs): proc.wait = AsyncMock(return_value=0) return proc - with patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), patch( - "asyncio.create_subprocess_exec", side_effect=_fake_exec - ), patch( - "kiro_crew.slack.gateway.repo_exec_config_reason", - return_value="", - ), patch( - "kiro_crew.slack.gateway.tracks_upstream", return_value=False + with ( + patch.dict("os.environ", {"KIROCREW_PROJECT_DIR": "/tmp/proj"}), + patch("asyncio.create_subprocess_exec", side_effect=_fake_exec), + patch( + "kiro_crew.slack.gateway.repo_exec_config_reason", + return_value="", + ), + patch("kiro_crew.slack.gateway.tracks_upstream", return_value=False), ): await orch._auto_apply_update() diff --git a/website/src/api/client.ts b/website/src/api/client.ts index b03ad274d76..5da30649487 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -3034,10 +3034,18 @@ export const api = { // slots sit on different projects, so project-scoped agents silently // vanish from the picker. Surfaces with no slot context (Channels, // Schedule) pass nothing and keep the global-only view. - kirocrewAgents: (sessionKey?: string) => - fetch('/api/agents', { + // + // `projectPath` is the raw-path fallback (Decision 1, cron project agents): + // a surface with no live slot (e.g. the Schedule page's job form) can still + // ask for a project's agents by passing the path directly. The server only + // honors it when sessionKey resolved to no project — a real slot's own + // project always wins, so this cannot override a live session's scope. + kirocrewAgents: (sessionKey?: string, projectPath?: string) => { + const qs = projectPath ? '?project_path=' + encodeURIComponent(projectPath) : '' + return fetch('/api/agents' + qs, { headers: sessionKey ? { 'X-Session-Key': sessionKey } : { ..._sk }, - }).then(j), + }).then(j) + }, /** The model a new session on this KiroCrew agent would run on. Empty * `agent` resolves the configured default agent. */ agentResolvedModel: (agent: string) => diff --git a/website/src/components/JobForm.tsx b/website/src/components/JobForm.tsx index 2fe2eda1a9f..6c146e06a28 100644 --- a/website/src/components/JobForm.tsx +++ b/website/src/components/JobForm.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Zap } from 'lucide-react' +import { Zap, FolderOpen } from 'lucide-react' import { api } from '../api/client' -import { Input, SendBtn } from './ui' +import { Btn, Input, SendBtn } from './ui' import { SettingsToggle } from './settings' import AgentSelector, { type KiroCrewAgent } from './AgentSelector' +import ProjectPicker from './ProjectPicker' import SimpleSelect from './SimpleSelect' import type { CronJob } from '../types' import type { CronPrefill } from '../utils/schedulePresets' @@ -57,7 +58,7 @@ export function jobKindOf(job?: CronJob): JobKind { /** Parse a CronJob into initial form state */ function parseJobDefaults(job?: CronJob) { - if (!job) return { name: '', message: '', agent: '', model: '', channel: '', approvalMode: '', silent: false, strictSchedule: false, hideInChat: false, minimalContext: false, jobKind: 'message' as JobKind, schedMode: 'interval' as const, intVal: 1, intUnit: 'hours' as const, weekDays: [] as number[], weekTime: '09:00', cronExpr: '' } + if (!job) return { name: '', message: '', agent: '', model: '', channel: '', approvalMode: '', silent: false, strictSchedule: false, hideInChat: false, minimalContext: false, jobKind: 'message' as JobKind, schedMode: 'interval' as const, intVal: 1, intUnit: 'hours' as const, weekDays: [] as number[], weekTime: '09:00', cronExpr: '', projectPath: '' } const isInterval = !!(job.every_secs || (job.schedule || '').match(/^every\s+\d+/)) const secs = job.every_secs || (() => { const m = (job.schedule || '').match(/^every\s+(\d+)\s*([smh])/); if (!m) return 3600; return parseInt(m[1]) * (m[2] === 'h' ? 3600 : m[2] === 'm' ? 60 : 1) })() // Largest unit that divides `secs` EVENLY, not the largest unit that is merely @@ -104,7 +105,28 @@ function parseJobDefaults(job?: CronJob) { weekDays = expandDow(cronParts[4]).map(d => CRON_DOW_TO_GRID[d] || 1) weekTime = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}` } - return { name: job.name, message: job.message, agent: job.agent || '', model: job.model || '', channel: job.channel || '', approvalMode: job.approval_mode || '', silent: job.silent || false, strictSchedule: job.strict_schedule || false, hideInChat: job.hide_in_chat || false, minimalContext: job.minimal_context || false, jobKind: jobKindOf(job), schedMode, intVal, intUnit, weekDays, weekTime, cronExpr: cronRaw } + return { name: job.name, message: job.message, agent: job.agent || '', model: job.model || '', channel: job.channel || '', approvalMode: job.approval_mode || '', silent: job.silent || false, strictSchedule: job.strict_schedule || false, hideInChat: job.hide_in_chat || false, minimalContext: job.minimal_context || false, jobKind: jobKindOf(job), schedMode, intVal, intUnit, weekDays, weekTime, cronExpr: cronRaw, projectPath: job.project_path || '' } +} + +/** Remap the backend's raw `project_path` validation errors (which use the + * wire field name and say nothing about the field being optional) to the + * UI's own field label, so a rejected path reads as a helpful correction + * rather than "failed to save" with no clue why. The backend's error + * vocabulary is shared with the CLI/MCP tool and other callers, so this + * stays a display-only remap here rather than a change to those strings. + * Falls through to the raw message for anything else (network errors, + * other 4xx/5xx) so nothing is silently swallowed. */ +function friendlyProjectPathError(raw: string): string { + if (raw.includes('project_path must be an absolute path')) { + return i18nT('components.jobForm.working_directory_must_be_an_absolute_path') + } + if (raw.includes('project_path refers to a sensitive path')) { + return i18nT('components.jobForm.working_directory_refers_to_a_protected_path') + } + if (raw.includes('project_path must be an existing directory')) { + return i18nT('components.jobForm.working_directory_must_be_an_existing_directory') + } + return raw } /** Build the API body from form state. Returns null if validation fails (sets error). */ @@ -140,6 +162,10 @@ function buildBody( body.silent = f.silent body.strict_schedule = f.strictSchedule body.hide_in_chat = f.hideInChat + // "" is a valid, meaningful value here (clears the binding back to + // global-agent-only on an edit), so it is sent unconditionally rather than + // gated behind a truthiness check like the optional fields above. + body.project_path = f.projectPath if (f.schedMode === 'interval') { body.every = f.intVal * (f.intUnit === 'minutes' ? 60 : f.intUnit === 'hours' ? 3600 : 86400) } else if (f.schedMode === 'weekly') { @@ -260,6 +286,100 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail const [weekTime, setWeekTime] = useState(init.weekTime) const [tz, setTz] = useState(() => job ? (job.timezone || 'UTC') : Intl.DateTimeFormat().resolvedOptions().timeZone) const [cronExpr, setCronExpr] = useState(init.cronExpr) + const [projectPath, setProjectPath] = useState(init.projectPath) + const [pickerOpen, setPickerOpen] = useState(false) + const browseRef = useRef(null) + // Project-scoped roster for THIS job's project_path — a raw path, no live + // chat slot behind it, so this is the project_path fallback (Decision 1). + // A local effect rather than useAgents(): that hook unconditionally syncs + + // fetches on every mount regardless of its args, which would double every + // JobForm's roster work even for the common case of no project_path set. + // This only does anything once a path is actually present. + const [projectAgents, setProjectAgents] = useState([]) + // Separate from the form-wide `error` (validation failures on Save): a + // background roster fetch failing must not borrow that channel, which + // (1) auto-scrolls the page to the bottom-of-form notice on every set, + // interrupting a user who is calmly typing elsewhere in the form for a + // failure unrelated to what they are doing, and (2) shares one string with + // Save-time validation, so a stale roster error can sit through an + // otherwise-successful save (only `handleSave`'s `setError('')` clears it), + // or a validation error can be silently clobbered by a late-resolving + // roster retry. Rendered beside the working-directory field itself instead. + const [projectRosterError, setProjectRosterError] = useState('') + useEffect(() => { + if (!projectPath) { + setProjectAgents([]) + setProjectRosterError('') + // The selected agent may have been a project-scoped one that only + // existed because THIS folder was open (effectiveAgents merged it in + // from projectAgents, now cleared above). Left alone, that name is + // still sent on save (`agent: locked ?? agent`) with an empty + // project_path -- Kiro cannot resolve it without the project scope + // and silently falls back to the default agent's prompt, tools, and + // permissions, with no error surfaced anywhere. Clear it back to the + // global default whenever it is not a name the global roster itself + // recognizes; a global agent that happens to share a name is left + // untouched, matching effectiveAgents' own dedup-by-name rule. + // `agents.length > 0` guards the roster itself being empty (still + // loading, or the fetch failed) -- without it, EVERY existing job's + // saved agent reads as "unrecognized" against an empty list and gets + // silently cleared on open, overwriting the persisted binding on the + // next save even though the user changed nothing. + setAgent(a => (a && agents.length > 0 && !agents.some(g => g.name === a) ? '' : a)) + return + } + // Clear immediately on folder change, before the fetch even starts: the + // `cancelled` flag below only stops a SUPERSEDED fetch's result from + // overwriting a newer one, but leaves the PREVIOUS folder's now-stale + // agents selectable in the picker for the whole in-flight gap. A user who + // switches folders and picks an agent in that gap would get an agent from + // the folder they just left. + setProjectAgents([]) + setProjectRosterError('') + let cancelled = false + api.kirocrewAgents(undefined, projectPath).then(d => { + if (!cancelled) setProjectAgents(d.agents || []) + }).catch(e => { + if (cancelled) return + // A roster-fetch failure must be VISIBLE, not a silent empty list: the + // user picked this folder specifically to see its agents, and an empty + // roster with no explanation reads as "this folder has none" rather + // than "the request failed" -- indistinguishable failure modes that + // need different next actions (retry vs. pick a different folder). + // api.kirocrewAgents throws ApiError/Error with an already-friendly + // message (apiFailure's friendlyErrText), so no remap is needed here -- + // friendlyProjectPathError is for the three raw project_path validation + // strings the SAVE path can surface, which this read endpoint does not. + setProjectAgents([]) + // Suffixed with the hand-off this failure causes: the agent picker + // below falls back to the global roster (effectiveAgents returns + // `agents` when projectAgents is empty), which the raw fetch error + // alone does not say -- without it, an empty-looking roster and a + // silently-substituted one are indistinguishable. + const rosterErr = e instanceof Error ? e.message : String(e) + setProjectRosterError( + `${rosterErr} ${i18nT('components.jobForm.project_roster_error_falls_back_to_global_agents')}`, + ) + }) + return () => { cancelled = true } + }, [projectPath, agents]) + // Global roster (the `agents` prop) plus this job's own project-scoped + // agents, deduped by name — a project agent that happens to share a global + // agent's name is not offered twice. Project rows arrive tagged + // `source: 'project'` by the server, and are shown as such without a + // per-folder relabel: this form only ever merges ONE folder's agents at a + // time, so the generic "project" badge already identifies where an agent + // came from unambiguously — a folder-name badge would only earn its keep + // if more than one folder's agents could appear in the same dropdown at + // once, which does not happen here. Merging here is the only way a per-job + // path (not known to the page-level roster) can ever appear in this picker + // at all. + const effectiveAgents = useMemo(() => { + if (!projectPath || projectAgents.length === 0) return agents + const globalNames = new Set(agents.map(a => a.name)) + const labeled = projectAgents.filter(a => !globalNames.has(a.name)) + return [...agents, ...labeled] + }, [agents, projectAgents, projectPath]) // Touched = any field diverged from what the form OPENED with. Compared // against `init`/`defaults` (the same sources the state seeded from), so a // value typed and then typed back reads as untouched again — the same rule @@ -276,6 +396,7 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail minimalContext !== defaults.minimalContext || intVal !== init.intVal || intUnit !== init.intUnit || weekTime !== init.weekTime || cronExpr !== init.cronExpr || + projectPath !== init.projectPath || weekDays.length !== init.weekDays.length || weekDays.some((d, i) => d !== init.weekDays[i]) const dirtyChangeRef = useRef(onDirtyChange) dirtyChangeRef.current = onDirtyChange @@ -326,7 +447,7 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail const submit = async () => { setError(''); setSaving(true) - const f = { name, message: msg, agent: locked ?? agent, model, channel, approvalMode, silent, strictSchedule, hideInChat, minimalContext, jobKind, schedMode, intVal, intUnit, weekDays, weekTime, cronExpr } + const f = { name, message: msg, agent: locked ?? agent, model, channel, approvalMode, silent, strictSchedule, hideInChat, minimalContext, jobKind, schedMode, intVal, intUnit, weekDays, weekTime, cronExpr, projectPath } const body = buildBody(f, tz, setError, !!job, job ? undefined : prefill) if (!body) { setSaving(false); return } if (boundMember && !isLlmless) { @@ -335,10 +456,10 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail } try { const res = job - ? await api.updateCron(job.id, body) + ? await api.updateCron(job.id, body).catch((e: Error) => ({ error: e.message })) : await api.createCron(body).catch((e: Error) => ({ error: e.message })) - if (res.error) { setError(res.error); setSaving(false); return } - if (!job) { setName(''); setMsg(''); setWeekDays([]); setIntVal(1); setChannel(''); setModel(''); setApprovalMode(''); setSilent(false); setStrictSchedule(false); setHideInChat(false); setMinimalContext(false) } + if (res.error) { setError(friendlyProjectPathError(res.error)); setSaving(false); return } + if (!job) { setName(''); setMsg(''); setWeekDays([]); setIntVal(1); setChannel(''); setModel(''); setApprovalMode(''); setSilent(false); setStrictSchedule(false); setHideInChat(false); setMinimalContext(false); setProjectPath('') } onSaved() } catch { setError(i18nT('components.jobForm.failed_to_save')); setSaving(false) } } @@ -402,7 +523,7 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail setMsg(e.target.value)} /> {locked ? - : setAgent(name)} rosterFailure={rosterFailure} modal />} + : setAgent(name)} rosterFailure={rosterFailure} modal />} - {/* Agent and Approval are agent/message concepts — script/command crons - run no LLM, so hide them (consistent with the LLM-less create surface). */} + {/* Working directory (like Agent/Approval below) is an agent/message + concept: it is only ever read at fire time by the LLM-agent cron + paths in gateway.py (single-agent and sequential), never by a + script/command job's subprocess dispatch in cron.py, which passes + no cwd derived from it. Showing the field for script/command jobs + would display help text that talks about "this job's agent" when + that job kind has none, and — since save-time validation runs + unconditionally on any non-empty project_path — could 400 the + save over a value the job would never actually use. Guarding it + the same as Agent/Approval keeps the field's presence consistent + with what fire time actually reads. */} {!isLlmless && (<> +
+ {i18nT('components.jobForm.working_directory')} ({i18nT('components.jobForm.optional')}) + {i18nT('components.jobForm.run_this_job_s_agent_in_this_folder_and_offer')} +
+ setProjectPath(e.target.value)} + placeholder="/Users/you/projects/myrepo" + /> + setPickerOpen(true)}> + {i18nT('components.jobForm.browse')} + +
+ {/* No hand-off: this notice sits inside the job form whose fields + (name, message, schedule, working directory) are still live — + the hand-off navigates to chat and would discard them. */} + {projectRosterError && ( + + )} +
{i18nT('components.jobForm.agent')} {locked ? : (<> {i18nT('components.jobForm.which_agent_handles_this_job_leave_default_for_t')} - setAgent(name)} rosterFailure={rosterFailure} modal /> + setAgent(name)} rosterFailure={rosterFailure} modal /> )}
)} @@ -569,6 +722,17 @@ export default function JobForm({ job, prefill, agents, defaultAgent, rosterFail
+ {/* Portals at z-[9999] via createPortal — reused rather than + * reimplemented so this folder picker is IDENTICAL to every other + * project-directory picker in the app (chat's own, FolderConfigModal's). */} + {pickerOpen && ( + { if (!o) setPickerOpen(false) }} + anchorRef={browseRef} + onSelect={path => { setProjectPath(path); setPickerOpen(false) }} + /> + )} ) } diff --git a/website/src/components/ProjectPicker.tsx b/website/src/components/ProjectPicker.tsx index c8ffc8b97b7..a2d2bb7b02f 100644 --- a/website/src/components/ProjectPicker.tsx +++ b/website/src/components/ProjectPicker.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback, RefObject } from 'react' import { useImeGuard } from '../hooks/useImeGuard' import { createPortal } from 'react-dom' +import { Branch as DismissableLayerBranch } from '@radix-ui/react-dismissable-layer' import { FolderOpen, ChevronRight, ChevronLeft, Clock, Search } from 'lucide-react' import { api } from '../api/client' import { useListKeyboardNav } from '../hooks/useListKeyboardNav' @@ -196,9 +197,69 @@ export default function ProjectPicker({ open, onOpenChange, anchorRef, anchorRec e.stopPropagation() } + // A modal Radix Dialog also runs `react-remove-scroll` (see the + // `pointer-events-auto` comment below for the sibling `pointer-events` + // issue this same "portals outside the dialog's own tree" shape causes). + // Unlike pointer-events, `react-remove-scroll` isn't fixable by a style + // override: it enforces the scroll lock with a REAL + // `document.addEventListener('wheel'/'touchmove', ..., { passive: false })` + // in the BUBBLE phase, gated on a `shards` allow-list that only contains + // the Dialog's own content ref — this popover was never added to it (it + // can't be, from here; `shards` is fixed once by ). The + // result, confirmed live: a real trackpad/wheel scroll over the popover's + // own directory list calls `scrollTop`/`scrollTo` internally, then + // `react-remove-scroll`'s document listener still fires on the same event + // and calls `preventDefault()`, undoing it — same failure shape as the + // click-passthrough bug, different Radix subsystem, so the earlier + // `pointer-events-auto` / `Branch` fixes don't touch it. + // + // Fix: intercept the wheel event during CAPTURE, before it can bubble to + // `document` at all. Capture always completes before bubble begins, so a + // capture listener on this popover's own root — closer to the event + // target than `document` — reliably runs first regardless of DOM order or + // React's own (unrelated) synthetic delegation. `stopPropagation` on the + // underlying native event is required: React's synthetic + // `stopPropagation` only stops OTHER REACT handlers, not an independent + // native `document.addEventListener` react-remove-scroll owns. + const allowNativeScroll = (e: React.WheelEvent) => { + e.nativeEvent.stopPropagation() + } + + // Same fix as allowNativeScroll, for the touch vector: react-remove-scroll's + // document-level scroll lock intercepts BOTH `wheel` and `touchmove` in + // `{ passive: false }`, so a touchscreen user's drag over the directory + // list was still eaten by the lock even with the wheel vector covered -- + // the list scrolled by mouse wheel but not by touch. + const allowNativeTouchScroll = (e: React.TouchEvent) => { + e.nativeEvent.stopPropagation() + } + return createPortal( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- keyboard-isolation barrier (see above), not an activatable control; there is no behaviour for a keyboard to be given, and every control inside here is a real input or button. Adding a role/tab stop would advertise an interaction this element does not have. -
{ + // Rendered as a Radix DismissableLayerBranch (not a plain
) because + // this popover portals to document.body as a REACT SIBLING of whatever + // hosts it — a plain Radix Dialog's DismissableLayer included. Radix's + // dismissable layer treats a branch's DOM subtree as "inside" for BOTH of + // its outside-dismiss checks (pointerdown-outside AND focus-outside), so + // a click or focus move into this popover no longer reads as "outside the + // dialog" and closes it out from under itself. A caller-side + // onPointerDownOutside override on covers only the first + // of those two vectors — Branch is the one fix that covers both, and it's + // a harmless no-op for callers with no Radix DismissableLayer ancestor + // (FolderConfigModal's hand-rolled Modal.tsx) since it just renders a div. + // + // `pointer-events-auto` is REQUIRED, separately from Branch: a modal + // Radix Dialog sets `document.body.style.pointerEvents = 'none'` while + // open (its own way of enforcing modality — see + // @radix-ui/react-dismissable-layer's `disableOutsidePointerEvents` + // handling) and this popover portals as a DIRECT CHILD OF BODY, so it + // inherits that `none` and every click silently passes through to + // whatever is visually underneath (verified live: `elementFromPoint` + // inside the popover's own bounding rect resolved to the dialog's own + // content, not the popover, purely from inherited `pointer-events`, with + // zero relation to z-index/paint order/DOM order — all of which were + // already correct). Branch alone does not restore this: it only exempts + // outside-click DISMISSAL logic, not the CSS pointer-events sweep. + { const dropMinH = 200 const spaceBelow = window.innerHeight - anchorR.bottom - 8 const flipUp = spaceBelow < dropMinH || anchorR.bottom > window.innerHeight / 2 @@ -339,7 +400,7 @@ export default function ProjectPicker({ open, onOpenChange, anchorRef, anchorRec
)} -
, + , document.body ) } diff --git a/website/src/hooks/useAgents.ts b/website/src/hooks/useAgents.ts index f3bd54469d0..aa13ac23937 100644 --- a/website/src/hooks/useAgents.ts +++ b/website/src/hooks/useAgents.ts @@ -5,11 +5,11 @@ import type { KiroCrewAgent } from '../components/AgentSelector' /** * @param sessionKey Chat-slot key whose project scope should apply. Omit on * surfaces with no slot context; project-scoped agents are then excluded. - * @param projectDir The slot's current project directory. The server resolves - * project-scoped agents from it, so it is part of this fetch's identity, not - * just an input to it: pointing the SAME slot at a different project changes - * the roster without changing `sessionKey`. Omit on surfaces with no slot - * context (the roster is then global-only and cannot go stale this way). + * @param projectDir The slot's current project directory. It is part of this + * fetch's identity, not an argument to it: it is deliberately NOT sent to the + * API (the server derives the slot's project itself), but pointing the SAME + * slot at a different project must refetch and drop the previous project's + * roster. Omit on surfaces with no slot context. * * @returns `error` — the roster fetch FAILED, as distinct from an install that * genuinely has one agent. The two used to be the same observation: the fetch diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index f66fa00d1bf..58da4f78357 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -6413,6 +6413,7 @@ "at": "সময়", "auto": "auto", "auto_approve": "স্বয়ংক্রিয় অনুমোদন", + "browse": "ব্রাউজ করুন", "channel_id": "চ্যানেল ID", "channel_id_optional": "চ্যানেল ID (ঐচ্ছিক)", "command": "কমান্ড", @@ -6444,6 +6445,12 @@ "name": "নাম", "name_is_required": "নাম দেওয়া বাধ্যতামূলক", "optional": "ঐচ্ছিক", + "working_directory": "কার্যকরী ডিরেক্টরি", + "working_directory_must_be_an_absolute_path": "কার্যকরী ডিরেক্টরি একটি অ্যাবসোলিউট পাথ হতে হবে (যেমন /Users/you/projects/myrepo)।", + "working_directory_must_be_an_existing_directory": "কার্যকরী ডিরেক্টরি একটি বিদ্যমান ডিরেক্টরি হতে হবে।", + "working_directory_refers_to_a_protected_path": "কার্যকরী ডিরেক্টরি একটি সুরক্ষিত সিস্টেম পাথ নির্দেশ করে এবং এটি ব্যবহার করা যাবে না।", + "project_roster_error_falls_back_to_global_agents": "পরিবর্তে গ্লোবাল এজেন্ট তালিকা দেখানো হচ্ছে।", + "run_this_job_s_agent_in_this_folder_and_offer": "এই জবের এজেন্টকে এই ফোল্ডারে চালান, এবং আপনার গ্লোবাল এজেন্টদের সাথে এর .kiro/agents/-এ সংজ্ঞায়িত এজেন্টগুলোও প্রদান করুন।", "override_the_model_for_this_job_leave_on_inherit": "এই জবের জন্য মডেল ওভাররাইড করুন। এজেন্ট বা গ্লোবাল ডিফল্ট ব্যবহার করতে “ইনহেরিট”-এ রেখে দিন।", "save": "সেভ করো", "saving": "সেভ হচ্ছে...", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 8909bada2b9..9551228658f 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -6413,6 +6413,7 @@ "at": "um", "auto": "automatisch", "auto_approve": "Automatisch genehmigen", + "browse": "Durchsuchen", "channel_id": "Kanal-ID", "channel_id_optional": "Kanal-ID (optional)", "command": "Befehl", @@ -6444,6 +6445,12 @@ "name": "Name", "name_is_required": "Name ist erforderlich", "optional": "Optional", + "working_directory": "Arbeitsverzeichnis", + "working_directory_must_be_an_absolute_path": "Das Arbeitsverzeichnis muss ein absoluter Pfad sein (z. B. /Users/du/projekte/meinrepo).", + "working_directory_must_be_an_existing_directory": "Das Arbeitsverzeichnis muss ein vorhandenes Verzeichnis sein.", + "working_directory_refers_to_a_protected_path": "Das Arbeitsverzeichnis verweist auf einen geschützten Systempfad und kann nicht verwendet werden.", + "project_roster_error_falls_back_to_global_agents": "Es wird stattdessen die globale Agentenliste angezeigt.", + "run_this_job_s_agent_in_this_folder_and_offer": "Führt den Agenten dieses Jobs in diesem Ordner aus und bietet zusätzlich zu deinen globalen Agenten die in dessen .kiro/agents/ definierten Agenten an.", "override_the_model_for_this_job_leave_on_inherit": "Das Modell für diesen Job übersteuern. Auf „Übernehmen“ belassen, um den Agenten- oder globalen Standard zu verwenden.", "save": "Speichern", "saving": "Wird gespeichert...", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 3ec793b8e34..8c8fceafac8 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -6009,6 +6009,7 @@ "at": "[àţ ···]", "auto": "[àùţø ······]", "auto_approve": "[Àùţø-àþþŕøṽè ···········]", + "browse": "[Ɓŕøẁşè ·········]", "channel_id": "[Çĥàññèĺ ÌÐ ···············]", "channel_id_optional": "[Çĥàññèĺ ÌÐ (øþţìøñàĺ) ···············]", "command": "[Çøɱɱàñð ···········]", @@ -6031,6 +6032,12 @@ "model_inherit": "[Ṁøðèĺ: ìñĥèŕìţ ·············]", "name": "[Ñàɱè ······]", "optional": "[Øþţìøñàĺ ············]", + "run_this_job_s_agent_in_this_folder_and_offer": "[Ŕùñ ţĥìş ĵøƀ'ş àğèñţ ìñ ţĥìş ƒøĺðèŕ, àñð øƒƒèŕ àñý àğèñţş ðèƒìñèð ìñ ìţş .ķìŕø/àğèñţş/ àĺøñğşìðè ýøùŕ ğĺøƀàĺ àğèñţş. ···································]", + "working_directory": "[Ẁøŕķìñğ ðìŕèçţøŕý ···············]", + "working_directory_must_be_an_absolute_path": "[Ẁøŕķìñğ ðìŕèçţøŕý ɱùşţ ƀè àñ àƀşøĺùţè þàţĥ (è.ğ. /Ùşèŕş/ýøù/þŕøĵèçţş/ɱýŕèþø). ·······················]", + "working_directory_must_be_an_existing_directory": "[Ẁøŕķìñğ ðìŕèçţøŕý ɱùşţ ƀè àñ èẋìşţìñğ ðìŕèçţøŕý. ························]", + "working_directory_refers_to_a_protected_path": "[Ẁøŕķìñğ ðìŕèçţøŕý ŕèƒèŕş ţø à þŕøţèçţèð şýşţèɱ þàţĥ àñð çàñ'ţ ƀè ùşèð. ·························]", + "project_roster_error_falls_back_to_global_agents": "[Şĥøẁìñğ ţĥè ğĺøƀàĺ àğèñţ ĺìşţ ìñşţèàð. ···················]", "override_the_model_for_this_job_leave_on_inherit": "[Øṽèŕŕìðè ţĥè ɱøðèĺ ƒøŕ ţĥìş ĵøƀ. Ĺèàṽè øñ \"Ìñĥèŕìţ\" ţø ùşè ţĥè àğèñţ øŕ ğĺøƀàĺ ðèƒàùĺţ. ··························]", "schedule": "[Şçĥèðùĺè ············]", "script": "[Şçŕìþţ ·········]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index 9c01a7ba91a..5b9e822c62e 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -4536,6 +4536,7 @@ "at": "at", "auto": "auto", "auto_approve": "Auto-approve", + "browse": "Browse", "channel_id": "Channel ID", "channel_id_optional": "Channel ID (optional)", "command": "Command", @@ -4558,6 +4559,12 @@ "model_inherit": "Model: inherit", "name": "Name", "optional": "Optional", + "run_this_job_s_agent_in_this_folder_and_offer": "Run this job's agent in this folder, and offer any agents defined in its .kiro/agents/ alongside your global agents.", + "working_directory": "Working directory", + "working_directory_must_be_an_absolute_path": "Working directory must be an absolute path (e.g. /Users/you/projects/myrepo).", + "working_directory_must_be_an_existing_directory": "Working directory must be an existing directory.", + "working_directory_refers_to_a_protected_path": "Working directory refers to a protected system path and can't be used.", + "project_roster_error_falls_back_to_global_agents": "Showing the global agent list instead.", "override_the_model_for_this_job_leave_on_inherit": "Override the model for this job. Leave on \"Inherit\" to use the agent or global default.", "schedule": "Schedule", "script": "Script", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index b54243d46a4..195fedae610 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -6515,6 +6515,7 @@ "at": "a las", "auto": "auto", "auto_approve": "Aprobar automáticamente", + "browse": "Explorar", "channel_id": "ID del canal", "channel_id_optional": "ID del canal (opcional)", "command": "Comando", @@ -6546,6 +6547,12 @@ "name": "Nombre", "name_is_required": "El nombre es obligatorio", "optional": "Opcional", + "working_directory": "Directorio de trabajo", + "working_directory_must_be_an_absolute_path": "El directorio de trabajo debe ser una ruta absoluta (p. ej., /Users/tu/proyectos/mirepo).", + "working_directory_must_be_an_existing_directory": "El directorio de trabajo debe ser un directorio existente.", + "working_directory_refers_to_a_protected_path": "El directorio de trabajo hace referencia a una ruta del sistema protegida y no se puede usar.", + "project_roster_error_falls_back_to_global_agents": "Mostrando la lista global de agentes en su lugar.", + "run_this_job_s_agent_in_this_folder_and_offer": "Ejecuta el agente de este trabajo en esta carpeta y ofrece los agentes definidos en su .kiro/agents/ junto con tus agentes globales.", "override_the_model_for_this_job_leave_on_inherit": "Anula el modelo para este trabajo. Déjalo en \"Heredar\" para usar el del agente o el predeterminado global.", "save": "Guardar", "saving": "Guardando…", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 56423e190d1..b097e08ad5e 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -6515,6 +6515,7 @@ "at": "à", "auto": "auto", "auto_approve": "Approuver automatiquement", + "browse": "Parcourir", "channel_id": "ID du canal", "channel_id_optional": "ID du canal (facultatif)", "command": "Commande", @@ -6546,6 +6547,12 @@ "name": "Nom", "name_is_required": "Le nom est obligatoire", "optional": "Facultatif", + "working_directory": "Répertoire de travail", + "working_directory_must_be_an_absolute_path": "Le répertoire de travail doit être un chemin absolu (par ex. /Users/vous/projets/monrepo).", + "working_directory_must_be_an_existing_directory": "Le répertoire de travail doit être un répertoire existant.", + "working_directory_refers_to_a_protected_path": "Le répertoire de travail fait référence à un chemin système protégé et ne peut pas être utilisé.", + "project_roster_error_falls_back_to_global_agents": "Affichage de la liste globale des agents à la place.", + "run_this_job_s_agent_in_this_folder_and_offer": "Exécute l'agent de cette tâche dans ce dossier et propose les agents définis dans son .kiro/agents/ en plus de vos agents globaux.", "override_the_model_for_this_job_leave_on_inherit": "Remplacer le modèle pour cette tâche. Laissez sur « Hériter » pour utiliser le modèle de l'agent ou le modèle global par défaut.", "save": "Enregistrer", "saving": "Enregistrement...", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 0911e16fe67..95850d50ff6 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -6413,6 +6413,7 @@ "at": "समय", "auto": "auto", "auto_approve": "स्वतः स्वीकृति", + "browse": "ब्राउज़ करें", "channel_id": "चैनल ID", "channel_id_optional": "चैनल ID (वैकल्पिक)", "command": "कमांड", @@ -6444,6 +6445,12 @@ "name": "नाम", "name_is_required": "नाम ज़रूरी है", "optional": "वैकल्पिक", + "working_directory": "कार्य डायरेक्टरी", + "working_directory_must_be_an_absolute_path": "कार्य डायरेक्टरी एक ऐब्सोल्यूट पथ होनी चाहिए (उदाहरण: /Users/you/projects/myrepo)।", + "working_directory_must_be_an_existing_directory": "कार्य डायरेक्टरी एक मौजूदा डायरेक्टरी होनी चाहिए।", + "working_directory_refers_to_a_protected_path": "कार्य डायरेक्टरी एक सुरक्षित सिस्टम पथ को संदर्भित करती है और इसका उपयोग नहीं किया जा सकता।", + "project_roster_error_falls_back_to_global_agents": "इसके बजाय ग्लोबल एजेंट सूची दिखाई जा रही है।", + "run_this_job_s_agent_in_this_folder_and_offer": "इस जॉब के एजेंट को इस फ़ोल्डर में चलाएं, और तुम्हारे ग्लोबल एजेंटों के साथ-साथ इसके .kiro/agents/ में परिभाषित एजेंट भी प्रस्तुत करें।", "override_the_model_for_this_job_leave_on_inherit": "इस जॉब के लिए मॉडल ओवरराइड करें। एजेंट या ग्लोबल डिफ़ॉल्ट उपयोग करने के लिए \"इनहेरिट\" पर छोड़ दें।", "save": "सहेजें", "saving": "सहेजा जा रहा है...", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 629803e6f04..9fa297be8ac 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -6515,6 +6515,7 @@ "at": "alle", "auto": "automatico", "auto_approve": "Approva automaticamente", + "browse": "Sfoglia", "channel_id": "ID canale", "channel_id_optional": "ID canale (facoltativo)", "command": "Comando", @@ -6546,6 +6547,12 @@ "name": "Nome", "name_is_required": "Il nome è obbligatorio", "optional": "Facoltativo", + "working_directory": "Directory di lavoro", + "working_directory_must_be_an_absolute_path": "La directory di lavoro deve essere un percorso assoluto (ad es. /Users/tu/progetti/miorepo).", + "working_directory_must_be_an_existing_directory": "La directory di lavoro deve essere una directory esistente.", + "working_directory_refers_to_a_protected_path": "La directory di lavoro fa riferimento a un percorso di sistema protetto e non può essere usata.", + "project_roster_error_falls_back_to_global_agents": "Verrà mostrato invece l'elenco globale degli agenti.", + "run_this_job_s_agent_in_this_folder_and_offer": "Esegue l'agente di questo job in questa cartella e offre gli agenti definiti nella sua .kiro/agents/ insieme ai tuoi agenti globali.", "override_the_model_for_this_job_leave_on_inherit": "Sostituisci il modello per questo job. Lascia su \"Eredita\" per usare il valore predefinito dell'agente o globale.", "save": "Salva", "saving": "Salvataggio in corso...", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index c189db2788a..05bbc1d3820 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -6311,6 +6311,7 @@ "at": "で", "auto": "自動", "auto_approve": "自動承認", + "browse": "参照", "channel_id": "チャネルID", "channel_id_optional": "チャネルID (オプション)", "command": "コマンド", @@ -6342,6 +6343,12 @@ "name": "名前", "name_is_required": "名前は必須です", "optional": "オプション", + "working_directory": "作業ディレクトリ", + "working_directory_must_be_an_absolute_path": "作業ディレクトリは絶対パスである必要があります(例: /Users/you/projects/myrepo)。", + "working_directory_must_be_an_existing_directory": "作業ディレクトリは既存のディレクトリである必要があります。", + "working_directory_refers_to_a_protected_path": "作業ディレクトリが保護されたシステムパスを参照しているため使用できません。", + "project_roster_error_falls_back_to_global_agents": "代わりにグローバルエージェント一覧を表示しています。", + "run_this_job_s_agent_in_this_folder_and_offer": "このジョブのエージェントをこのフォルダーで実行し、グローバルエージェントに加えて、その .kiro/agents/ で定義されたエージェントも提供します。", "override_the_model_for_this_job_leave_on_inherit": "このジョブのモデルをオーバーライドします。エージェントまたはグローバルのデフォルトを使用する場合は「継承」のままにしてください。", "save": "保存", "saving": "保存中…", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index be631ddb8f7..7f19b20c217 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -6311,6 +6311,7 @@ "at": "시각", "auto": "자동", "auto_approve": "자동 승인", + "browse": "찾아보기", "channel_id": "채널 ID", "channel_id_optional": "채널 ID (선택 사항)", "command": "명령", @@ -6342,7 +6343,13 @@ "name": "이름", "name_is_required": "이름은 필수입니다", "optional": "선택 사항", - "override_the_model_for_this_job_leave_on_inherit": "이 작업의 모델을 재정의합니다. 에이전트 또는 전역 기본값을 사용하려면 ‘상속’으로 두세요.", + "working_directory": "작업 디렉터리", + "working_directory_must_be_an_absolute_path": "작업 디렉터리는 절대 경로여야 합니다(예: /Users/you/projects/myrepo).", + "working_directory_must_be_an_existing_directory": "작업 디렉터리는 실제로 존재하는 디렉터리여야 합니다.", + "working_directory_refers_to_a_protected_path": "작업 디렉터리가 보호된 시스템 경로를 참조하므로 사용할 수 없습니다.", + "project_roster_error_falls_back_to_global_agents": "대신 전역 에이전트 목록을 표시합니다.", + "run_this_job_s_agent_in_this_folder_and_offer": "이 작업의 에이전트를 이 폴더에서 실행하고, 전역 에이전트와 함께 해당 .kiro/agents/에 정의된 에이전트도 제공합니다.", + "override_the_model_for_this_job_leave_on_inherit": "이 작업의 모델을 재정의합니다. 에이전트 또는 전역 기본값을 사용하려면 '상속'으로 두세요.", "save": "저장", "saving": "저장 중…", "schedule": "스케줄", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index b55a6215024..0542f6ba4d2 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -6515,6 +6515,7 @@ "at": "às", "auto": "auto", "auto_approve": "Aprovar automaticamente", + "browse": "Procurar", "channel_id": "ID do canal", "channel_id_optional": "ID do canal (opcional)", "command": "Comando", @@ -6546,6 +6547,12 @@ "name": "Nome", "name_is_required": "O nome é obrigatório", "optional": "Opcional", + "working_directory": "Diretório de trabalho", + "working_directory_must_be_an_absolute_path": "O diretório de trabalho deve ser um caminho absoluto (ex.: /Users/voce/projetos/meurepo).", + "working_directory_must_be_an_existing_directory": "O diretório de trabalho deve ser um diretório existente.", + "working_directory_refers_to_a_protected_path": "O diretório de trabalho faz referência a um caminho de sistema protegido e não pode ser usada.", + "project_roster_error_falls_back_to_global_agents": "Mostrando a lista global de agentes em vez disso.", + "run_this_job_s_agent_in_this_folder_and_offer": "Executa o agente deste job nesta pasta e oferece os agentes definidos em seu .kiro/agents/, além dos seus agentes globais.", "override_the_model_for_this_job_leave_on_inherit": "Substitui o modelo deste job. Deixe em \"Herdar\" para usar o padrão do agente ou o global.", "save": "Salvar", "saving": "Salvando...", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index cbb5c247f80..f5d8dc65f3a 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -6617,6 +6617,7 @@ "at": "в", "auto": "авто", "auto_approve": "Автоподтверждение", + "browse": "Обзор", "channel_id": "ID канала", "channel_id_optional": "ID канала (необязательно)", "command": "Команда", @@ -6648,6 +6649,12 @@ "name": "Название", "name_is_required": "Нужно указать название", "optional": "Необязательно", + "working_directory": "Рабочий каталог", + "working_directory_must_be_an_absolute_path": "Рабочий каталог должен быть абсолютным путём (например, /Users/вы/проекты/мойрепозиторий).", + "working_directory_must_be_an_existing_directory": "Рабочий каталог должен быть существующим каталогом.", + "working_directory_refers_to_a_protected_path": "Рабочий каталог указывает на защищённый системный путь и не может быть использован.", + "project_roster_error_falls_back_to_global_agents": "Вместо этого показывается глобальный список агентов.", + "run_this_job_s_agent_in_this_folder_and_offer": "Запускает агента этого задания в этой папке и предлагает агентов, определённых в её .kiro/agents/, вместе с вашими глобальными агентами.", "override_the_model_for_this_job_leave_on_inherit": "Переопределить модель для этого задания. Оставьте «Наследовать», чтобы использовать модель агента или глобальную по умолчанию.", "save": "Сохранить", "saving": "Сохранение...", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 11a3caf8121..972a57d418c 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -6311,6 +6311,7 @@ "at": "于", "auto": "自动", "auto_approve": "自动审批", + "browse": "浏览", "channel_id": "频道 ID", "channel_id_optional": "频道 ID(可选)", "command": "命令", @@ -6342,6 +6343,12 @@ "name": "名称", "name_is_required": "名称不能为空", "optional": "可选", + "working_directory": "工作目录", + "working_directory_must_be_an_absolute_path": "工作目录必须是绝对路径(例如 /Users/you/projects/myrepo)。", + "working_directory_must_be_an_existing_directory": "工作目录必须是一个已存在的目录。", + "working_directory_refers_to_a_protected_path": "工作目录指向受保护的系统路径,无法使用。", + "project_roster_error_falls_back_to_global_agents": "改为显示全局代理列表。", + "run_this_job_s_agent_in_this_folder_and_offer": "可选。此任务将在该文件夹中运行对应代理,并把 .kiro/agents/ 中定义的代理与全局代理一起提供选择。", "override_the_model_for_this_job_leave_on_inherit": "为此任务覆盖模型。保持“继承”则使用代理或全局默认值。", "save": "保存", "saving": "保存中…", diff --git a/website/src/test/FileChangeChipsAnim.test.tsx b/website/src/test/FileChangeChipsAnim.test.tsx index 8e97f1a7b01..3939f467a1e 100644 --- a/website/src/test/FileChangeChipsAnim.test.tsx +++ b/website/src/test/FileChangeChipsAnim.test.tsx @@ -107,8 +107,15 @@ describe('chip row collapse animation', () => { fireEvent.keyDown(row, { key: 'Enter' }) await waitFor(() => expect(document.activeElement).toBe(chevron())) + // `completeOpenFocus` moves focus and clears the imperative `tabindex` + // attribute synchronously, but `role` is a declarative prop driven by + // `focusProxy` state -- its removal from the DOM waits on React's next + // render commit after `setFocusProxy(false)`, a separate async step the + // focus-only `waitFor` above does not cover. Asserting on it immediately + // races that commit under load (observed failing on a throttled CI + // runner, passing reliably on an idle local machine); wait for it too. + await waitFor(() => expect(row).not.toHaveAttribute('role')) expect(latest().unsafeCSS).not.toContain('fccHide') - expect(row).not.toHaveAttribute('role') expect(row).not.toHaveAttribute('tabindex') }) diff --git a/website/src/test/JobForm.projectOnlyAgentReset.test.tsx b/website/src/test/JobForm.projectOnlyAgentReset.test.tsx new file mode 100644 index 00000000000..4ac8e3fede9 --- /dev/null +++ b/website/src/test/JobForm.projectOnlyAgentReset.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest' +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { renderWithProviders } from './helpers' +import JobForm from '../components/JobForm' +import type { CronJob } from '../types' +import { api } from '../api/client' + +vi.mock('../api/client', () => ({ + api: { + updateCron: vi.fn(), + createCron: vi.fn(), + models: vi.fn().mockResolvedValue({ models: [] }), + kirocrewAgents: vi.fn().mockResolvedValue({ agents: [], default_agent: '' }), + }, +})) + +function messageJob(overrides: Partial = {}): CronJob { + return { + id: 'j1', name: 'nightly', message: 'do the thing', schedule: '', enabled: true, + cron_expr: '0 3 * * *', ...overrides, + } as CronJob +} + +/** + * Selecting a project-scoped agent (only offered because a working + * directory was set), then clearing that working directory, previously left + * the picker's `agent` state untouched: `effectiveAgents` correctly fell + * back to the global roster once `projectAgents` cleared, but the SELECTED + * name was never reset, so save sent that now-unresolvable name with an + * empty `project_path`. Kiro cannot resolve a project agent without its + * project scope and silently falls back to the default agent's prompt, + * tools, and permissions -- with no error surfaced anywhere to say so. + * Fixed by clearing the selection back to the global default whenever it is + * not a name the global roster itself recognizes. + */ +describe('JobForm clears a project-only agent selection when the working directory is cleared', () => { + it('resets the agent picker to the default once the project agent is no longer resolvable', async () => { + vi.mocked(api.kirocrewAgents).mockResolvedValueOnce({ + agents: [{ + name: 'repo-bot', kiro_agent: 'repo-bot', workspace: 'repo', memory_store: 'repo', + description: 'repo agent', source: 'project', + }], + default_agent: '', + }) + renderWithProviders( + {}} + layout="vertical" + />, + ) + + fireEvent.change(screen.getByLabelText('Working directory'), { + target: { value: '/Users/you/projects/myrepo' }, + }) + await waitFor(() => expect(api.kirocrewAgents).toHaveBeenCalled()) + + // Select the project-only agent via the AgentSelector's listbox. + fireEvent.click(screen.getByLabelText('Switch agent')) + fireEvent.click(screen.getByRole('option', { name: /repo-bot/ })) + await waitFor(() => expect(screen.getByLabelText('Switch agent')).toHaveTextContent('repo-bot')) + + // Now clear the working directory -- the project-only agent must no + // longer be shown as selected. + fireEvent.change(screen.getByLabelText('Working directory'), { target: { value: '' } }) + + await waitFor(() => + expect(screen.getByLabelText('Switch agent')).toHaveTextContent('default'), + ) + expect(screen.getByLabelText('Switch agent')).not.toHaveTextContent('repo-bot') + }) + + it('leaves a global agent selection untouched when the working directory clears', async () => { + // A global agent that happens to be selected must survive the folder + // clearing -- this gate only targets names the global roster does not + // recognize. + renderWithProviders( + {}} + layout="vertical" + />, + ) + + fireEvent.click(screen.getByLabelText('Switch agent')) + fireEvent.click(screen.getByRole('option', { name: /ea-dev/ })) + await waitFor(() => expect(screen.getByLabelText('Switch agent')).toHaveTextContent('ea-dev')) + + fireEvent.change(screen.getByLabelText('Working directory'), { + target: { value: '/Users/you/projects/myrepo' }, + }) + await waitFor(() => expect(api.kirocrewAgents).toHaveBeenCalled()) + fireEvent.change(screen.getByLabelText('Working directory'), { target: { value: '' } }) + + expect(screen.getByLabelText('Switch agent')).toHaveTextContent('ea-dev') + }) + + it('does not clear an existing job binding when the global roster is empty or still loading', async () => { + // Opus 4.8 finding: `!projectPath` runs on EVERY mount (every existing + // job has an empty project_path by default), and `agents.some(...)` on + // an empty/not-yet-loaded roster is always false for ANY saved agent + // name -- without the `agents.length > 0` guard, opening ANY existing + // message job while the roster fetch failed or is still in flight would + // silently clear its persisted agent binding, and a later save would + // overwrite it with the default even though the user changed nothing. + renderWithProviders( + {}} + layout="vertical" + />, + ) + + // The job's saved agent must still be shown as selected -- not reset to + // the (also empty) default just because the roster hasn't loaded. + expect(screen.getByLabelText('Switch agent')).toHaveTextContent('ea-dev') + }) +}) diff --git a/website/src/test/JobForm.projectRosterError.test.tsx b/website/src/test/JobForm.projectRosterError.test.tsx new file mode 100644 index 00000000000..f7d938b89be --- /dev/null +++ b/website/src/test/JobForm.projectRosterError.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest' +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { renderWithProviders } from './helpers' +import JobForm from '../components/JobForm' +import type { CronJob } from '../types' +import { api } from '../api/client' + +vi.mock('../api/client', () => ({ + api: { + updateCron: vi.fn(), + createCron: vi.fn(), + models: vi.fn().mockResolvedValue({ models: [] }), + kirocrewAgents: vi.fn().mockResolvedValue({ agents: [], default_agent: '' }), + }, +})) + +function messageJob(overrides: Partial = {}): CronJob { + return { + id: 'j1', name: 'nightly', message: 'do the thing', schedule: '', enabled: true, + cron_expr: '0 3 * * *', ...overrides, + } as CronJob +} + +/** + * The project-scoped roster fetch (`api.kirocrewAgents` keyed by working + * directory) had its `.catch()` write into the SAME `error` state as + * Save-time field validation. Two real bugs from that: (1) `setError` also + * auto-scrolls the page to the bottom-of-form notice on every set, so a + * background fetch failing mid-typing would yank the user's scroll position + * for a failure unrelated to what they were doing; (2) the two failure kinds + * share one string with no independent clear, so a stale roster error could + * survive an otherwise-successful save (only the Save handler's own + * `setError('')` clears it) or a validation error could be silently + * clobbered by a late-resolving roster retry. Fixed by giving the roster + * fetch its own state, rendered beside the working-directory field via the + * same `ErrorNotice` component the sibling `AgentSelector` roster-failure UI + * already uses, instead of the form-wide notice. + */ +describe('JobForm project roster fetch failure stays out of the form-wide error', () => { + it('shows the roster failure beside the working-directory field, not in the form-wide notice', async () => { + vi.mocked(api.kirocrewAgents).mockRejectedValueOnce(new Error('network unreachable')) + renderWithProviders( + {}} layout="vertical" />, + ) + + fireEvent.change(screen.getByLabelText('Working directory'), { + target: { value: '/Users/you/projects/myrepo' }, + }) + + await waitFor(() => + expect(screen.getByTestId('jobform-project-roster-error')).toHaveTextContent('network unreachable'), + ) + // The form-wide notice (Save-time validation, no testId) must stay + // absent -- a background roster failure is not a reason to show it or + // scroll the page. Both notices share `role="alert"`, so a bare + // `queryByRole` would match this test's own roster notice; assert on + // the untagged one specifically by excluding the testId'd element. + const alerts = screen.queryAllByRole('alert') + expect(alerts.every(el => el.getAttribute('data-testid') === 'jobform-project-roster-error')).toBe(true) + }) + + it('keeps the roster failure visible after an unrelated successful save -- it describes the folder, not the save outcome', async () => { + vi.mocked(api.kirocrewAgents).mockRejectedValueOnce(new Error('network unreachable')) + vi.mocked(api.updateCron).mockResolvedValue({ ok: true }) + const onSaved = vi.fn() + renderWithProviders( + , + ) + + fireEvent.change(screen.getByLabelText('Working directory'), { + target: { value: '/Users/you/projects/myrepo' }, + }) + await waitFor(() => expect(screen.getByTestId('jobform-project-roster-error')).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)) + // A prior implementation shared one `error` string between the roster + // fetch and Save-time validation, whose only clear point was the Save + // handler's own `setError('')` -- so a successful save happened to also + // wipe the (unrelated) roster message as a side effect, and the reverse + // held too: a real validation error could be clobbered by a late roster + // retry. The roster notice is now independent state describing the + // CURRENT folder, so it is unaffected by an unrelated save outcome -- + // it still needs its own retry/folder-change to clear, checked below. + expect(screen.getByTestId('jobform-project-roster-error')).toBeInTheDocument() + }) + + it('clears the roster failure when the folder is changed to one that resolves', async () => { + vi.mocked(api.kirocrewAgents) + .mockRejectedValueOnce(new Error('network unreachable')) + .mockResolvedValueOnce({ agents: [], default_agent: '' }) + renderWithProviders( + {}} layout="vertical" />, + ) + + const input = screen.getByLabelText('Working directory') + fireEvent.change(input, { target: { value: '/Users/you/projects/myrepo' } }) + await waitFor(() => expect(screen.getByTestId('jobform-project-roster-error')).toBeInTheDocument()) + + fireEvent.change(input, { target: { value: '/Users/you/projects/other' } }) + await waitFor(() => + expect(screen.queryByTestId('jobform-project-roster-error')).not.toBeInTheDocument(), + ) + }) +}) diff --git a/website/src/test/JobForm.saveError.test.tsx b/website/src/test/JobForm.saveError.test.tsx new file mode 100644 index 00000000000..cfe8695cbcc --- /dev/null +++ b/website/src/test/JobForm.saveError.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest' +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { renderWithProviders } from './helpers' +import JobForm from '../components/JobForm' +import type { CronJob } from '../types' +import { api } from '../api/client' + +vi.mock('../api/client', () => ({ + api: { + updateCron: vi.fn(), + createCron: vi.fn(), + models: vi.fn().mockResolvedValue({ models: [] }), + kirocrewAgents: vi.fn().mockResolvedValue({ agents: [], default_agent: '' }), + }, +})) + +function messageJob(overrides: Partial = {}): CronJob { + return { + id: 'j1', name: 'nightly', message: 'do the thing', schedule: '', enabled: true, + cron_expr: '0 3 * * *', ...overrides, + } as CronJob +} + +/** + * `createCron` had its own `.catch((e: Error) => ({ error: e.message }))` + * from the start, but `updateCron` (the EDIT path) had none — so a rejected + * PATCH (e.g. the backend's `project_path` validation) skipped past the + * `if (res.error)` branch entirely and fell into the generic + * `catch { setError('Failed to save') }`, discarding the real backend + * message. Confirmed live: editing a job with an invalid working directory + * always showed "Failed to save" with no way to tell why. These pin BOTH + * halves of the fix — updateCron's own error surfaces at all, AND the + * project_path-specific messages get remapped to the UI's own field label + * rather than the raw backend field name. + */ +describe('cron JobForm surfaces the real update error (not "Failed to save")', () => { + it('shows updateCron\'s rejection message instead of the generic failed-to-save text', async () => { + vi.mocked(api.updateCron).mockRejectedValue(new Error('project_path must be an absolute path')) + renderWithProviders( + {}} layout="vertical" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + 'Working directory must be an absolute path (e.g. /Users/you/projects/myrepo).', + ) + }) + expect(screen.queryByText('Failed to save')).not.toBeInTheDocument() + }) + + it('remaps the "existing directory" rejection to the field label, with no backend field name in it', async () => { + vi.mocked(api.updateCron).mockRejectedValue( + new Error('project_path must be an existing directory'), + ) + renderWithProviders( + {}} layout="vertical" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + 'Working directory must be an existing directory.', + ) + }) + expect(screen.queryByText(/project_path/)).not.toBeInTheDocument() + }) + + it('remaps the sensitive-path rejection to the field label', async () => { + vi.mocked(api.updateCron).mockRejectedValue( + new Error('project_path refers to a sensitive path'), + ) + renderWithProviders( + {}} layout="vertical" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + "Working directory refers to a protected system path and can't be used.", + ) + }) + }) + + it('falls through to the raw message for a non-project_path rejection', async () => { + vi.mocked(api.updateCron).mockRejectedValue(new Error('Agent \'ea-dev\' not found')) + renderWithProviders( + {}} layout="vertical" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent("Agent 'ea-dev' not found") + }) + }) + + it('still saves successfully when updateCron resolves', async () => { + vi.mocked(api.updateCron).mockResolvedValue({ ok: true }) + const onSaved = vi.fn() + renderWithProviders( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/website/src/test/JobForm.scriptCommand.test.tsx b/website/src/test/JobForm.scriptCommand.test.tsx index 40e84a24b0c..9e16f9c2362 100644 --- a/website/src/test/JobForm.scriptCommand.test.tsx +++ b/website/src/test/JobForm.scriptCommand.test.tsx @@ -103,6 +103,11 @@ describe('JobForm script/command edit path', () => { expect(screen.getByText('Script')).toBeInTheDocument() expect(screen.queryByText('Agent')).not.toBeInTheDocument() expect(screen.queryByText('Approval')).not.toBeInTheDocument() + // Working directory is an agent/message concept -- a script job's fire-time + // dispatch in cron.py never reads project_path, so the field (and its + // agent-specific help text) must not render for this job kind either. + expect(screen.queryByText('Working directory')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Working directory')).not.toBeInTheDocument() // Channel still available for all kinds expect(screen.getByText('Channel ID')).toBeInTheDocument() }) @@ -119,5 +124,10 @@ describe('JobForm script/command edit path', () => { ) expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('Approval')).toBeInTheDocument() + // An agent/message job's fire-time path DOES read project_path (the + // single-agent and sequential cron paths in gateway.py), so the working + // directory field must render for this job kind. + expect(screen.getByText('Working directory')).toBeInTheDocument() + expect(screen.getByLabelText('Working directory')).toBeInTheDocument() }) }) diff --git a/website/src/types/index.ts b/website/src/types/index.ts index 910679e7565..8f281203e72 100644 --- a/website/src/types/index.ts +++ b/website/src/types/index.ts @@ -423,6 +423,7 @@ export interface CronJob { /** IANA timezone the cron expression's hour/minute fields are stored in. * Absent / null for legacy jobs created without an explicit TZ — treat as UTC. */ timezone?: string | null + project_path?: string | null skip_dates?: string[] | null script?: string | null; command?: string | null; last_result?: string | null; last_error?: string | null is_running?: boolean; running_since?: number | null