From 1c913c0636d44e67971ab4c07f9387e37a1d9665 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:06:47 +0800 Subject: [PATCH 1/2] fix(chat): validate periodic Task input before source access Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat_goal_configuration_api.py | 4 +++- tests/test_managed_session_calendar_tasks.py | 25 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index e803099925..92f98d4489 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -272,7 +272,9 @@ def _goal_capability_options( return {option: cadence["count"], **horizon, **({"execution_stride_strategy": cadence["stride_strategy"]} if "stride_strategy" in cadence else {})} if capability_id == "periodic_report": - return {"periodic_report_configuration": config} + from .capabilities.periodic_report.goal_configuration import normalize_configuration + # Validate the payload before the Session guard reads native sources. + return {"periodic_report_configuration": normalize_configuration(config)} if capability_id == "reward_memory": config_path = config.get("config_path") if config_path is not None and not isinstance(config_path, str): diff --git a/tests/test_managed_session_calendar_tasks.py b/tests/test_managed_session_calendar_tasks.py index b2c722ced9..66447aaec7 100644 --- a/tests/test_managed_session_calendar_tasks.py +++ b/tests/test_managed_session_calendar_tasks.py @@ -350,6 +350,31 @@ def test_task_declarations_never_enroll_other_routes_or_implicit_commands(fault) normalize_configuration(config) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["missing-task", "empty-task", "array-task", "injected-command"]) +def test_settings_reject_invalid_task_input_without_execution_or_config_write(steward, tmp_path, monkeypatch, provider, fault): + session, actor, _, capture, _, config = setup_task(steward, tmp_path, monkeypatch, provider) + store, runtime, bot, *_ = steward + if fault == "missing-task": + config.pop("task") + elif fault == "empty-task": + config["task"] = {} + elif fault == "array-task": + config["task"] = [] + else: + config["task"]["validation_command"] = ["untrusted-command"] + before = runtime.registry_path.read_bytes(), capture.read_bytes(), store.messages(session["session_id"]), list(bot.writes) + with settings_server(steward) as request: + body = {"goal_id": actor["goal_id"], "capability_id": "periodic_report", "configuration": config} + for path, payload in (("preview", body), ("apply", {**body, "expected_plan_revision": "not-a-plan"})): + status, rejected = request("/api/chat/goal-configuration/" + path, payload) + assert status == 400, rejected + assert rejected["error_code"] == "invalid_goal_configuration" + assert "session task requires one existing source_todo_id" in rejected["error"] + assert (runtime.registry_path.read_bytes(), capture.read_bytes(), store.messages(session["session_id"]), list(bot.writes)) == before + assert read_cadence_journal(runtime_root=runtime.coordination_runtime_root, goal_id=actor["goal_id"]) is None + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_known_failed_repeat_is_retained_without_replay_then_explicitly_cancelled(steward, tmp_path, monkeypatch, provider): session, actor, _, capture, workspace, config = setup_task(steward, tmp_path, monkeypatch, provider) From 77ced11aa8427b9b79bf0dcf76dd44214ccac213 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:06:47 +0800 Subject: [PATCH 2/2] test(chat): exercise native calendar Tasks in the packaged fixture Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/periodic-reports.md | 38 ++++++++++- .../steward_report_fixture.py | 66 +++++++++++++++---- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/docs/product/periodic-reports.md b/docs/product/periodic-reports.md index 972bdb2c06..744ee06a4c 100644 --- a/docs/product/periodic-reports.md +++ b/docs/product/periodic-reports.md @@ -1011,15 +1011,47 @@ The existing Goal capability configuration transaction accepts: Replace the example with the actual existing identities and requested time. Prepare through `/api/chat/goal-configuration/preview`, then apply the same body and its `expected_plan_revision` through -`/api/chat/goal-configuration/apply`. The existing Goal settings JSON editor -uses this transaction; read back the saved configuration and the managed -Session's `scheduled_task` inspection. A stale preview or changed identity is +`/api/chat/goal-configuration/apply`. Read back the saved configuration and the +managed Session's `scheduled_task` inspection. A stale preview or changed identity is rejected. This is an explicit Goal override: a machine default cannot enroll another conversation. `session-task` is a calendar execution mode, not a report renderer preset; it does not invoke the progress-report source or editorial format. Task descriptions, scripts and validation argv cannot be supplied in this configuration. +The current generic Goal editor does **not** register the `task` field. Its +form drops that source from the draft, and its JSON editor rejects unregistered +fields. It therefore cannot yet configure or edit this Task mode end to end; +the HTTP contract above and the read-only source catalog are partial product +delivery. Do not mistake a preconfigured fixture for a completed Task picker or +natural-language journey. Missing, empty, non-object or command-bearing Task +input is rejected as HTTP 400 before Session source inspection, without changing +the subscription, creating a window or invoking work. It does not infer a source +from an older configuration. + +当前通用 Goal 编辑器尚未登记 `task` 字段:表单会在草稿中丢失原来源,JSON 编辑器也 +拒绝未登记字段,因此完整任务配置界面仍未交付。上述 HTTP 合同和只读来源目录只是 +阶段能力,预配置的合成场景不代表已完成 Task 选择器或自然语言旅程。缺失、空值、 +非对象或夹带命令的 Task 输入在读取 Session 来源前即以 HTTP 400 拒绝,不改订阅、 +不创建窗口、不执行业务,也不从旧配置推断来源。 + +For reproducible packaged checks, run the existing disposable browser fixture: + +```sh +uv run --extra test python examples/personal-workspace-browser/steward_report_fixture.py \ + --managed-calendar-task sqlite --output-dir /tmp/loopx-calendar-task-qa +``` + +Use `file` instead of `sqlite` for the other native backend. `ready.json` names +the isolated HTTP URL, original Session, completed source and future disabled +configuration. The model computes an independently validated synthetic sum; +no real identity or private provider is attached. The `start-existing-owners` +control starts the real idle wake owner. `stop-existing-wake` stops only that +owner; removing it resumes the same stored state through a new wake-service +instance. `observed.json` records artifact, completion, retained history and +actual post-setup model calls. This qualifies neither live-model understanding, +complete Chat-process restart nor production delivery. + Callers can discover eligible references on demand with `GET /api/chat/sessions//native-turn?calendar_task_sources=true`. The `calendar_task_sources` view reads the original Goal's canonical completed diff --git a/examples/personal-workspace-browser/steward_report_fixture.py b/examples/personal-workspace-browser/steward_report_fixture.py index ad4b625b2c..ae3cb2c37a 100644 --- a/examples/personal-workspace-browser/steward_report_fixture.py +++ b/examples/personal-workspace-browser/steward_report_fixture.py @@ -1,7 +1,7 @@ """Disposable packaged UI + native HTTP/Core fixture; model/provider are synthetic. Run with the project's development dependencies and --output-dir outside Git. -The two control files change synthetic provider readiness or start the existing +Control files change synthetic provider readiness or start the existing host wake/outbound owners. No real account, Goal, workspace or listener is used. """ from __future__ import annotations @@ -23,13 +23,15 @@ def main() -> None: parser.add_argument("--baseline-assets-dir", type=Path) parser.add_argument("--invalid-editorial", action="store_true", help="Return a known invalid synthetic report to inspect owner cancellation and retained failure.") parser.add_argument("--host-failure", action="store_true", help="Fail the synthetic native model before an editorial result; retain the original window without retry authority.") - parser.add_argument("--managed-session", action="store_true", help="Exercise ordinary Goal Session native work with the existing task validator, instead of a scheduled report.") - parser.add_argument("--managed-report", action="store_true", help="Configure a calendar report for an existing local Agent Session through the packaged Goal settings.") + journey = parser.add_mutually_exclusive_group() + journey.add_argument("--managed-session", action="store_true", help="Exercise ordinary Goal Session native work with the existing task validator, instead of a scheduled report.") + journey.add_argument("--managed-report", action="store_true", help="Configure a calendar report for an existing local Agent Session through the packaged Goal settings.") + journey.add_argument("--managed-calendar-task", choices=("file", "sqlite"), help="Repeat a completed, independently validated Task through the real calendar and packaged Goal settings.") args = parser.parse_args() - if args.managed_session and args.managed_report: - parser.error("select one managed Session fixture journey") - if args.host_failure and (args.invalid_editorial or args.managed_session): + if args.host_failure and (args.invalid_editorial or args.managed_session or args.managed_calendar_task): parser.error("select the pre-editorial report failure journey alone") + if args.invalid_editorial and (args.managed_session or args.managed_calendar_task): + parser.error("editorial failure requires a report journey") repo = Path(__file__).resolve().parents[2] sys.path[:0] = [str(repo), str(repo / "tests")] from test_native_steward_private import steward @@ -50,13 +52,32 @@ def main() -> None: data = next(fixture) store, runtime, provider, transport, binding, capture, workspace = data managed_session = None - if args.managed_report: + local_owner = None + if args.managed_calendar_task: + import pytest + from test_managed_session_calendar_tasks import setup_task + from loopx.configure_goal import configure_goal + from loopx.capabilities.native_chat.session_calendar_tasks import SessionCalendarTasks + monkeypatch = pytest.MonkeyPatch() + managed_session, actor, _, capture, task_workspace, configuration = setup_task( + data, root, monkeypatch, args.managed_calendar_task) + sid, managed = managed_session["session_id"], transport.managed_steward + local_tasks = SessionCalendarTasks(runtime) + local_owner = local_tasks + managed_todo_id = configuration["task"]["source_todo_id"] + configuration = {**configuration, "schedule": {**configuration["schedule"], "start_date": "2999-01-01"}} + configure_goal(registry_path=runtime.registry_path, goal_id=actor["goal_id"], + periodic_report_configuration={**configuration, "enabled": False}, execute=True) + artifact = task_workspace / "total.json" + artifact.unlink() # The next period must produce and validate its own artifact. + elif args.managed_report: import pytest from test_native_steward_reports import configured_local_report from loopx.configure_goal import configure_goal monkeypatch = pytest.MonkeyPatch() managed_session, local_reports, configuration, managed_todo_id = configured_local_report( data, root, monkeypatch, "sqlite", start_date="2999-01-01") + local_owner = local_reports sid = managed_session["session_id"] actor = local_reports.selected(sid)["actor"] managed = transport.managed_steward @@ -76,6 +97,13 @@ def main() -> None: actor = managed.prepare(binding["binding_id"], session_id=sid, execute=True, expected_plan_id=plan["plan_id"])["actor"] install_model(data, root, sid) + if managed_session: + from loopx.capabilities.native_chat.steward_reports import ManagedSessionReports + # A seed helper also prepares a private steward. This journey uses + # the returned local Session, not that separate preparation actor. + local_owner = local_owner or ManagedSessionReports(runtime) + sid = managed_session["session_id"] + actor = local_owner.selected(sid)["actor"] transport.reconcile() if args.host_failure: from loopx.chat_agent import CodexChatAgentError @@ -118,23 +146,32 @@ def fail_report(_adapter, _prompt, event_sink): "fixture_pid": os.getpid(), "binding_id": binding["binding_id"], "session_id": sid, **({"managed_session_id": managed_session["session_id"], "goal_id": actor["goal_id"], - "managed_todo_id": managed_todo_id} if managed_session else {})})) + "managed_todo_id": managed_todo_id} if managed_session else {}), + **({"authority_provider": args.managed_calendar_task, + "calendar_task_configuration": configuration} if args.managed_calendar_task else {})})) wake, outbound, delivery = None, None, None + owner_start_count = 0 try: while not stop.wait(.2): server.assets_dir = current_assets if (output / "use-current-assets").exists() else baseline_assets provider.owner_ready = not (output / "provider-unavailable").exists() - if wake is None and (output / "start-existing-owners").exists(): + owners_stopped = (output / "stop-existing-wake").exists() + if owners_stopped and wake: + wake.close() + wake = None + if wake is None and not owners_stopped and (output / "start-existing-owners").exists(): wake = DelegationWakeService(runtime, interval=.1).start() - if not args.managed_report: + owner_start_count += 1 + if not (args.managed_report or args.managed_calendar_task) and outbound is None: outbound = LarkGoalTopicRuntimeService(snapshot_provider=lambda: {}, runtime_root=runtime.coordination_runtime_root, runtime_controller=runtime, private_conversations=transport) delivery = threading.Thread(target=outbound._reconcile_private) delivery.start() + calendar_health = local_tasks.health(sid) if args.managed_calendar_task else None (output / "observed.json").write_text(json.dumps({ - "report": local_reports.health(sid) if args.managed_report else StewardReports(runtime).health(binding["binding_id"]), - "same_actor": local_reports.selected(sid)["actor"] == actor if args.managed_report else managed.health(binding["binding_id"])["session_id"] == actor["session_id"], + "report": local_reports.health(sid) if args.managed_report else None if args.managed_calendar_task else StewardReports(runtime).health(binding["binding_id"]), + "same_actor": local_owner.selected(sid)["actor"] == actor if local_owner else managed.health(binding["binding_id"])["session_id"] == actor["session_id"], "history_prefix_retained": store.messages(sid)[:len(before_messages)] == before_messages, "model_call_count": sum(json.loads(line).get("method") == "turn/start" for line in capture.read_bytes()[len(before_calls):].splitlines()), @@ -142,10 +179,15 @@ def fail_report(_adapter, _prompt, event_sink): "private_outbound_alive": delivery.is_alive() if delivery else None, "private_ingress": transport.ingress.health(binding["binding_id"]), "provider_history_pages": sum("+chat-messages-list" in call for call in provider.calls), + "existing_wake_owner_running": wake is not None, + "existing_wake_owner_starts": owner_start_count, **({"managed_source_present": artifact.exists(), "managed_session": store.load_session(managed_session["session_id"])} if args.managed_session else {}), **({"managed_report": True, "managed_session": store.load_session(managed_session["session_id"])} if args.managed_report else {}), + **({"calendar_task": calendar_health, + "calendar_artifact": json.loads(artifact.read_text()) if calendar_health.get("completion_verified") else None, + "managed_session": store.load_session(sid)} if args.managed_calendar_task else {}), "synthetic_only": True}, ensure_ascii=False)) finally: if wake: