diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 71e678432..f39eb69cc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1047,6 +1047,10 @@ explicit confirmation; TTY interaction and human participation are not prerequis `--interactive-confirm` is optional. Autonomous runs retain the same authenticated owner, draftVersion, paramsDigest, confirmationId, expiry, Java save/readback and restart/recovery checks; this does not auto-confirm ordinary end-user workflows. +Editing a confirmed draft invalidates its confirmation ID. Saving with that ID +returns HTTP 409 (`confirmation_mismatch`, code 40900), including after further +edits; an unconfirmed request with an unrelated ID returns HTTP 400. The internal +invalidation marker is persisted but excluded from public confirmation metadata. Artifacts identify `confirmation_actor=autonomous` and keep `human_demo_completed` false. `workflow_demo_completed` requires a non-synthetic completed service flow; mocked tests remain synthetic and cannot satisfy formal acceptance. Legacy field diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 1fe82a13e..64b71a166 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-f26b77d34a904e1fae301357a5569b657309e95ac1a84ec1e6b6a6d735f130c3", - "chunk_id": "repository-development:sha256-f26b77d34a904e1fae301357a5569b657309e95ac1a84ec1e6b6a6d735f130c3:1", + "version": "sha256-8fd11b59eea6f0663bf45ad4261f6ab2e183b2098b3308c18dc05891c64a2b58", + "chunk_id": "repository-development:sha256-8fd11b59eea6f0663bf45ad4261f6ab2e183b2098b3308c18dc05891c64a2b58:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", diff --git a/services/agent/src/agent_service/app.py b/services/agent/src/agent_service/app.py index 74e8585cb..1516f7125 100644 --- a/services/agent/src/agent_service/app.py +++ b/services/agent/src/agent_service/app.py @@ -711,9 +711,11 @@ async def execute_action(_, analysis_result): raise AgentError(400, "validation_error") draft = dict(row["draft"]) draft.update({"title": title, "content": content}) + invalidated_id = row["confirmation"].get("id") or row["confirmation"].get("invalidatedId") changed = workflow_store.transition(thread_id, owner, expected_run=str(row["run_id"]), expected_version=version, statuses={"awaiting_confirmation", "confirmed"}, changes={"status": "awaiting_confirmation", "draft": draft, - "draft_version": version + 1, "confirmation": {}}, kind="draft_edited") + "draft_version": version + 1, + "confirmation": {"invalidatedId": invalidated_id} if invalidated_id else {}}, kind="draft_edited") return {"status": changed["status"], "run_id": changed["run_id"], "draft_version": changed["draft_version"], "action_result": {"response": _thread_response(changed, owner)}} @@ -953,6 +955,8 @@ async def execute_action(_, analysis_result): "draft_version": version, "action_result": {"response": response}} digest = params_digest(owner, str(row["source_submission_id"]), version, str(row["draft"]["title"]), str(row["draft"]["content"])) + if confirmation.get("invalidatedId") == body["confirmationId"]: + raise AgentError(409, "confirmation_mismatch", 40900) if row["status"] != "confirmed" or confirmation.get("id") != body["confirmationId"]: raise AgentError(400, "confirmation_required") if row["cancel_requested"] or row["save_attempted"]: diff --git a/services/agent/tests/test_agent_security.py b/services/agent/tests/test_agent_security.py index b708060a2..e5731020f 100644 --- a/services/agent/tests/test_agent_security.py +++ b/services/agent/tests/test_agent_security.py @@ -237,7 +237,8 @@ async def scenario(client): assert edited.status_code == 200 response = await client.post(f"/agent/threads/{thread_id}/save", headers=HEADERS, json={"confirmationId": confirmation_id}) - assert response.status_code == 409 if expiry else response.status_code == 400 + assert response.status_code == 409 + assert response.json()["code"] == 40900 assert posts["count"] == 0 asyncio.run(with_client(app, scenario)) diff --git a/services/agent/tests/test_agent_service.py b/services/agent/tests/test_agent_service.py index c94944690..e78e22270 100644 --- a/services/agent/tests/test_agent_service.py +++ b/services/agent/tests/test_agent_service.py @@ -813,6 +813,51 @@ async def scenario(): asyncio.run(scenario()) +def test_edited_draft_rejects_invalidated_confirmation_before_fresh_save(tmp_path, monkeypatch): + monkeypatch.setattr("agent_service.app.load_u02_gate", lambda *args, **kwargs: {"test_only": True}) + + async def scenario(): + posts, saved, post_flags = {"count": 0}, {}, [] + app = create_app(state_path=tmp_path / "stale-confirmation.sqlite3", + client_factory=_write_client(posts, saved, post_flags), + u02_gate_path=tmp_path / "gate.json", expected_head="0" * 40) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + state = (await client.post("/agent/threads", headers=HEADERS, + json={"sourceSubmissionId": str(uuid.uuid4()), "question": "如何复盘?"})).json()["data"] + path = f"/agent/threads/{state['threadId']}" + missing = await client.post(f"{path}/save", headers=HEADERS, + json={"confirmationId": str(uuid.uuid4())}) + assert missing.status_code == 400 + confirmed = await client.post(f"{path}/confirm", headers=HEADERS, + json={"draftVersion": 1, "paramsDigest": state["paramsDigest"], "confirm": True}) + old_id = confirmed.json()["data"]["confirmation"]["id"] + for version in (1, 2): + edited = await client.put(f"{path}/draft", headers=HEADERS, + json={"draftVersion": version, "title": "修改后的计划", "content": "重新确认后才能保存"}) + assert edited.status_code == 200 + state = edited.json()["data"] + assert state["confirmation"] == {} + stale = await client.post(f"{path}/save", headers=HEADERS, + json={"confirmationId": old_id}) + assert stale.status_code == 409 + assert stale.json()["code"] == 40900 + assert posts["count"] == 0 + unrelated = await client.post(f"{path}/save", headers=HEADERS, + json={"confirmationId": str(uuid.uuid4())}) + assert unrelated.status_code == 400 + fresh = await client.post(f"{path}/confirm", headers=HEADERS, + json={"draftVersion": 3, "paramsDigest": state["paramsDigest"], "confirm": True}) + assert fresh.status_code == 200 + result = await client.post(f"{path}/save", headers=HEADERS, + json={"confirmationId": fresh.json()["data"]["confirmation"]["id"]}) + assert result.status_code == 200 + assert result.json()["data"]["status"] == "saved" + assert posts["count"] == 1 + + asyncio.run(scenario()) + + def test_state_mutations_run_inside_dispatch_node(tmp_path, monkeypatch): monkeypatch.setattr("agent_service.app.load_u02_gate", lambda *args, **kwargs: {"test_only": True})