cloudagent-daemon 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/CHANGELOG.md +31 -0
  2. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.2/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/claude_runner.py +135 -22
  6. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/cli.py +11 -1
  7. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/main_loop.py +24 -3
  8. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/mcp_bridge.py +124 -17
  9. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/outbound_queue.py +3 -0
  10. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/paths.py +3 -0
  11. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/protocol.py +10 -1
  12. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_mcp_bridge_round_trip.py +91 -0
  13. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_spawn_mcp_bridge_e2e.py +26 -0
  14. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner.py +87 -12
  15. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_cli_smoke.py +1 -0
  16. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_mcp_bridge_agent_retrieval.py +14 -2
  17. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_mcp_bridge_send_message.py +5 -0
  18. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_outbound_queue.py +11 -0
  19. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_paths.py +7 -0
  20. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/uv.lock +1 -1
  21. cloudagent_daemon-0.5.0/src/cloudagent_daemon/__init__.py +0 -1
  22. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/.gitignore +0 -0
  23. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/LICENSE +0 -0
  24. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/README.md +0 -0
  25. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/scripts/release.sh +0 -0
  26. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/__main__.py +0 -0
  27. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_download.py +0 -0
  28. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_upload.py +0 -0
  29. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  30. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/auth_preflight.py +0 -0
  31. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/config.py +0 -0
  32. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/context_fetch.py +0 -0
  33. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/delegation_report.py +0 -0
  34. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/doctor.py +0 -0
  35. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  36. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/http_outbound.py +0 -0
  37. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/injection_state.py +0 -0
  38. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  39. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/login.py +0 -0
  40. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/metrics.py +0 -0
  41. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/metrics_server.py +0 -0
  42. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  43. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  44. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/runtime_config.py +0 -0
  45. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/runtime_detect.py +0 -0
  46. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/service.py +0 -0
  47. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  48. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/spawn_queue.py +0 -0
  49. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/sse_client.py +0 -0
  50. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/transport.py +0 -0
  51. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/unix_socket.py +0 -0
  52. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/workspace.py +0 -0
  53. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/ws_client.py +0 -0
  54. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/__init__.py +0 -0
  55. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/_fake_sse_server.py +0 -0
  56. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/conftest.py +0 -0
  57. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  58. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_e2e_sse.py +0 -0
  59. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_full_e2e.py +0 -0
  60. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_one_shot_cli.py +0 -0
  61. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_sse_handshake.py +0 -0
  62. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_sse_watchdog.py +0 -0
  63. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_ws_handshake.py +0 -0
  64. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/integration/test_ws_reconnect.py +0 -0
  65. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/conftest.py +0 -0
  66. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_agent_download.py +0 -0
  67. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_agent_upload.py +0 -0
  68. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_auth_preflight.py +0 -0
  69. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  70. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  71. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_w3.py +0 -0
  72. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_cli_multi_profile.py +0 -0
  73. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_config.py +0 -0
  74. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_context_fetch.py +0 -0
  75. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_delegation_report.py +0 -0
  76. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_doctor.py +0 -0
  77. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_frame_dispatch.py +0 -0
  78. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_http_outbound.py +0 -0
  79. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_injection_state.py +0 -0
  80. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_jsonl_logging.py +0 -0
  81. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_login.py +0 -0
  82. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_main_loop_assign.py +0 -0
  83. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_main_loop_preflight.py +0 -0
  84. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_metrics.py +0 -0
  85. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_metrics_server.py +0 -0
  86. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_multi_tool_wake.py +0 -0
  87. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  88. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_packaging_mcp_pin.py +0 -0
  89. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.2}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,37 @@ All notable changes to the local daemon. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are
5
5
  [PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
6
6
 
7
+ ## [0.5.1] — 2026-08-20
8
+
9
+ ### Added
10
+ - Qoder engine now supports cross-owner `read_analyze` (only-read remote
11
+ command). Previously the daemon fail-closed refused `read_analyze` on qoder
12
+ because qodercli's tool-restriction was never verified end-to-end (#106).
13
+ This release ships a proven limited-tool surface:
14
+ - qodercli launched with `--setting-sources '' --permission-mode
15
+ bypass_permissions --tools Read Glob Grep --no-session-persistence
16
+ --strict-mcp-config` and a dedicated read_analyze MCP config; no `--resume`
17
+ of the owner lane.
18
+ - `mcp-bridge --tool-profile read_analyze` applies a POSITIVE allowlist at
19
+ MCP `tools/list` time: only 13 membership-gated query tools remain; every
20
+ write/mutation tool (`platform_send`, `room_post`, `send_message`,
21
+ upload/download, reminder/task mutation, react, login, chief tools) is
22
+ removed. Unknown profile → `ValueError` (fail-closed).
23
+ - Final analysis is delivered by the daemon-controlled fallback to the
24
+ backend-supplied origin conversation only; the model cannot choose a
25
+ destination.
26
+ Verified by real cross-owner E2E (human non-owner → qoder agent, induced
27
+ write/command/`platform_send` all blocked, read-only result returned to the
28
+ origin channel). Confidentiality scope is intentionally the target agent's
29
+ own read ACL (same-org cross-owner read/analysis is the product contract).
30
+
31
+ ### Known debt
32
+ - mcp-bridge per-spawn token-file is still not wired end-to-end; bridge/daemon
33
+ IPC trust rests on the same-user 0600 unix socket, so a malicious native
34
+ process under the same OS user could forge an agent_id. Pre-existing and
35
+ shared by all engines/run-modes; tracked separately (#110), not introduced
36
+ by this release.
37
+
7
38
  ## [0.5.0] — 2026-08-19
8
39
 
9
40
  First release since 0.4.1 (2026-07-22). Everything below shipped to internal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.2"
@@ -391,6 +391,7 @@ class ClaudeRunner:
391
391
  partial_publisher: PartialPublisher | None = None,
392
392
  event_publisher: EventPublisher | None = None,
393
393
  agent_to_session: "dict[str, str | None] | None" = None,
394
+ agent_to_delivery: "dict[str, str | None] | None" = None,
394
395
  claude_permission_mode: str = "bypassPermissions",
395
396
  runtime_binaries: dict[str, str] | None = None,
396
397
  http: object | None = None,
@@ -440,6 +441,10 @@ class ClaudeRunner:
440
441
  # unix socket ``get_current_session`` op instead of reading a file.
441
442
  # None means feature disabled (legacy / test path).
442
443
  self._agent_to_session: dict[str, str | None] | None = agent_to_session
444
+ # #112 — opaque backend delivery provenance for the currently running
445
+ # turn. The MCP bridge queries this map through daemon IPC and adds it
446
+ # to outbound calls without exposing a forgeable tool parameter.
447
+ self._agent_to_delivery: dict[str, str | None] | None = agent_to_delivery
443
448
 
444
449
  # ------------------------------------------------------------------
445
450
  # Public API
@@ -618,6 +623,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
618
623
  # reading the file. Both paths coexist for backward compat.
619
624
  if self._agent_to_session is not None:
620
625
  self._agent_to_session[agent_id] = _effective_session_id
626
+ current_delivery_id = payload.get("delivery_id")
627
+ _effective_delivery_id = (
628
+ current_delivery_id
629
+ if isinstance(current_delivery_id, str) and current_delivery_id
630
+ else None
631
+ )
632
+ if self._agent_to_delivery is not None:
633
+ self._agent_to_delivery[agent_id] = _effective_delivery_id
621
634
 
622
635
  # M7 W3 — localagent company-context injection. BEFORE spawning
623
636
  # claude, if this deliver belongs to a task whose context has not
@@ -709,6 +722,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
709
722
  # (c) IPC architecture upgrade — clear from memory map.
710
723
  if self._agent_to_session is not None:
711
724
  self._agent_to_session.pop(agent_id, None)
725
+ if self._agent_to_delivery is not None:
726
+ self._agent_to_delivery.pop(agent_id, None)
712
727
 
713
728
  captured_session = attempt.captured_session_id
714
729
  rc = attempt.exit_code
@@ -830,6 +845,11 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
830
845
  "content": attempt.final_result_text,
831
846
  "conversation_id": platform_conv_id,
832
847
  "acknowledge_new_messages": True,
848
+ **(
849
+ {"delivery_id": _effective_delivery_id}
850
+ if _effective_delivery_id
851
+ else {}
852
+ ),
833
853
  },
834
854
  timeout=15.0,
835
855
  )
@@ -876,6 +896,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
876
896
  payload, attempt.final_result_text
877
897
  )
878
898
  )
899
+ if _effective_delivery_id:
900
+ fallback_args["delivery_id"] = _effective_delivery_id
879
901
  await self.outbound.send_tool_call(
880
902
  agent_id=agent_id,
881
903
  session_id=fallback_session,
@@ -1006,7 +1028,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1006
1028
  agent_id=agent_id,
1007
1029
  session_id=fallback_session,
1008
1030
  tool_name="send_message",
1009
- args=self._fallback_reply_args(payload, abort_body),
1031
+ args={
1032
+ **self._fallback_reply_args(payload, abort_body),
1033
+ **(
1034
+ {"delivery_id": _effective_delivery_id}
1035
+ if _effective_delivery_id
1036
+ else {}
1037
+ ),
1038
+ },
1010
1039
  timeout=15.0,
1011
1040
  )
1012
1041
  try:
@@ -1026,15 +1055,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1026
1055
  reason,
1027
1056
  )
1028
1057
 
1029
- await self.outbound.send_agent_status(
1030
- agent_id,
1031
- final_status,
1032
- exit_code=rc,
1033
- conversation_id=(
1058
+ final_status_kwargs: dict[str, Any] = {
1059
+ "exit_code": rc,
1060
+ "conversation_id": (
1034
1061
  str(payload["conversation_id"])
1035
1062
  if payload.get("conversation_id")
1036
1063
  else None
1037
1064
  ),
1065
+ }
1066
+ if _effective_delivery_id:
1067
+ final_status_kwargs["delivery_id"] = _effective_delivery_id
1068
+ await self.outbound.send_agent_status(
1069
+ agent_id,
1070
+ final_status,
1071
+ **final_status_kwargs,
1038
1072
  )
1039
1073
 
1040
1074
  wake_secs = time.monotonic() - wake_start
@@ -1382,6 +1416,9 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1382
1416
  wake_prompt = self._codex_stdin_preamble(
1383
1417
  agent_id, append_system_prompt,
1384
1418
  read_analyze=run_mode == "read_analyze",
1419
+ daemon_managed_reply=(
1420
+ runtime_engine == "qoder" and run_mode == "read_analyze"
1421
+ ),
1385
1422
  ) + wake_prompt
1386
1423
  try:
1387
1424
  assert proc.stdin is not None # for type checkers
@@ -1400,14 +1437,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1400
1437
  await self.outbound.send_ack(
1401
1438
  seq=None, message_id=message_id, status="started"
1402
1439
  )
1403
- await self.outbound.send_agent_status(
1404
- agent_id,
1405
- "running",
1406
- conversation_id=(
1440
+ running_status_kwargs: dict[str, Any] = {
1441
+ "conversation_id": (
1407
1442
  str(payload["conversation_id"])
1408
1443
  if payload.get("conversation_id")
1409
1444
  else None
1410
- ),
1445
+ )
1446
+ }
1447
+ delivery_id = payload.get("delivery_id")
1448
+ if isinstance(delivery_id, str) and delivery_id:
1449
+ running_status_kwargs["delivery_id"] = delivery_id
1450
+ await self.outbound.send_agent_status(
1451
+ agent_id,
1452
+ "running",
1453
+ **running_status_kwargs,
1411
1454
  )
1412
1455
 
1413
1456
  # 1k74 — token-level streaming. Forward the deliver's session_id
@@ -1519,6 +1562,19 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1519
1562
  "mcp__cloudagent__platform_task_status",
1520
1563
  )
1521
1564
 
1565
+ # #107 — qoder read_analyze is enforced with a positive allowlist, not a
1566
+ # denylist. qodercli 1.1.26 live probes showed that ``--tools`` removes
1567
+ # every other built-in capability from the init event even under
1568
+ # ``bypass_permissions``; the latter is still required so the injected
1569
+ # cloudagent MCP server can post the analysis result in headless mode.
1570
+ # Keep this deliberately small: WebFetch/WebSearch are excluded to avoid
1571
+ # an untrusted cross-owner prompt exfiltrating files it just read.
1572
+ QODER_READ_ANALYZE_BUILTIN_TOOLS: tuple[str, ...] = (
1573
+ "Read",
1574
+ "Glob",
1575
+ "Grep",
1576
+ )
1577
+
1522
1578
  # 1i Bug F — operational contract for daemon-spawned wakes.
1523
1579
  # Without this, claude in --print mode just dumps its answer to stdout
1524
1580
  # and exits, never invoking send_message — so the user never sees a reply.
@@ -1875,10 +1931,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1875
1931
  "提交给 owner 审核 —— owner 批准后再由 owner 指挥自己的 agent 实际落盘。"
1876
1932
  "不要试图直接改文件;被拦不是故障,是权限设计。"
1877
1933
  )
1934
+ QODER_READ_ANALYZE_REPLY_NOTE = (
1935
+ "【Qoder 只读回报例外】本模式刻意不向模型暴露 platform_send、"
1936
+ "room_post、send_message 或任何写类 MCP 工具。不要尝试调用这些工具;"
1937
+ "请把完整分析作为本轮最终文本直接输出。daemon 会把该最终文本经受控"
1938
+ "fallback 发回 backend 下发的原始 conversation_id,目标不可由你更改。"
1939
+ )
1878
1940
 
1879
1941
  def _codex_stdin_preamble(
1880
1942
  self, agent_id: str, append_system_prompt: str | None,
1881
- *, read_analyze: bool = False,
1943
+ *, read_analyze: bool = False, daemon_managed_reply: bool = False,
1882
1944
  ) -> str:
1883
1945
  """The system contract as a stdin preamble — codex's substitute for
1884
1946
  ``--append-system-prompt`` (which only the claude argv path has).
@@ -1891,6 +1953,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1891
1953
  parts = [self.SYSTEM_PROMPT_APPEND.format(agent_id=agent_id)]
1892
1954
  if read_analyze:
1893
1955
  parts.append(self.READ_ANALYZE_SYSTEM_NOTE)
1956
+ if daemon_managed_reply:
1957
+ parts.append(self.QODER_READ_ANALYZE_REPLY_NOTE)
1894
1958
  if append_system_prompt and append_system_prompt.strip():
1895
1959
  parts.append(append_system_prompt.strip())
1896
1960
  joined = "\n\n".join(parts)
@@ -1952,6 +2016,38 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1952
2016
  exc_info=True,
1953
2017
  )
1954
2018
 
2019
+ @staticmethod
2020
+ def _qoder_read_analyze_mcp_config(
2021
+ ws: AgentWorkspaceLike, agent_id: str
2022
+ ) -> Path:
2023
+ """Write the per-wake, query-only cloudagent MCP config for qoder.
2024
+
2025
+ The regular workspace config exposes state-changing tools (send,
2026
+ upload, task mutations, reminders). ``--tools`` only restricts qoder
2027
+ built-ins, so read_analyze needs a separately profiled bridge whose
2028
+ ``tools/list`` contains query-only operations. The daemon fallback,
2029
+ not the model, posts the final analysis back to the source channel.
2030
+ """
2031
+ from .paths import build_mcp_bridge_args, daemon_executable
2032
+
2033
+ config_path = ws.path / ".meta" / "qoder-read-analyze-mcp-config.json"
2034
+ token_file = str(ws.path / ".meta" / "claude-token.txt")
2035
+ config = {
2036
+ "mcpServers": {
2037
+ "cloudagent": {
2038
+ "command": daemon_executable(),
2039
+ "args": build_mcp_bridge_args(
2040
+ agent_id,
2041
+ token_file,
2042
+ tool_profile="read_analyze",
2043
+ ),
2044
+ }
2045
+ }
2046
+ }
2047
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2048
+ config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
2049
+ return config_path
2050
+
1955
2051
  def _build_cmd(
1956
2052
  self,
1957
2053
  ws: AgentWorkspaceLike,
@@ -2061,20 +2157,14 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2061
2157
  # stream-json/--append-system-prompt/--mcp-config/--resume 全对齐,
2062
2158
  # 输出走既有 stream_json 消费器(联调时如发现事件形状偏差再适配)。
2063
2159
  # 认证:QODER_PERSONAL_ACCESS_TOKEN 环境变量或 qodercli login。
2064
- if read_analyze:
2065
- # fail-closed:qoder 的只读硬化(--tools/--disallowed-tools 的
2066
- # 内建工具名、--permission-mode 无头语义)尚未在真机验证 ——
2067
- # 未验证的安全属性不许上线(#55 口径),宁可拒绝 spawn。
2068
- raise RuntimeError(
2069
- "qoder runtime does not support run_mode=read_analyze yet "
2070
- "(read-only hardening flags unverified); refusing to spawn"
2071
- )
2072
2160
  binary = self.runtime_binaries.get("qoder") or "qodercli"
2073
2161
  # #95 — 会话按 conversation_id 分轨(DM/各频道各自续聊),不再共用
2074
2162
  # 全局 last_session;无 conversation_id 的非平台唤醒沿用全局轨。
2163
+ # #107 — read_analyze 与 claude/codex 一样绝不 resume owner 轨,
2164
+ # 防外部指挥上下文渗进后续 full 会话。
2075
2165
  lane_session = (
2076
2166
  self._qoder_lane_session(ws, conversation_id)
2077
- if with_resume else None
2167
+ if with_resume and not read_analyze else None
2078
2168
  )
2079
2169
  cmd = [binary]
2080
2170
  # r6 同款:模型 + 推理强度由 chairman 在编辑页选(qoder 档位
@@ -2085,13 +2175,18 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2085
2175
  cmd += ["--reasoning-effort", reasoning_effort]
2086
2176
  if lane_session:
2087
2177
  cmd += ["--resume", lane_session]
2178
+ mcp_config_path = (
2179
+ self._qoder_read_analyze_mcp_config(ws, agent_id)
2180
+ if read_analyze
2181
+ else ws.mcp_config_path()
2182
+ )
2088
2183
  # #95 — 运营契约 + 本轮 append(含频道级提示)**不走 argv
2089
2184
  # --append-system-prompt**:qodercli --resume 不可靠采用新的 append,
2090
2185
  # 改与 codex 同款折进 stdin 活跃前导(见 _spawn_attempt),每轮必到
2091
2186
  # 模型眼前,不依赖 CLI 对 resume 的语义。
2092
2187
  cmd += [
2093
2188
  "--mcp-config",
2094
- str(ws.mcp_config_path()),
2189
+ str(mcp_config_path),
2095
2190
  "--print",
2096
2191
  "--output-format",
2097
2192
  "stream-json",
@@ -2100,6 +2195,24 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2100
2195
  "--permission-mode",
2101
2196
  "bypass_permissions",
2102
2197
  ]
2198
+ if read_analyze:
2199
+ # #107 — qodercli 1.1.26 双机真测:--tools 是 built-in
2200
+ # capability allowlist;在 bypass_permissions 下 init.tools
2201
+ # 仍精确为 Glob/Grep/Read,Bash/Write/Edit/NotebookEdit/Web*
2202
+ # 不可调用,诱导写文件的探针保持不存在。同时 bypass 让
2203
+ # cloudagent MCP 能在 --print 无头模式回报结果。
2204
+ #
2205
+ # 空 setting-sources 不加载 user/project/local hooks;strict
2206
+ # MCP 只保留 daemon 生成的 cloudagent 配置;禁用 session
2207
+ # persistence,配合上面的 no-resume 保证 owner 会话隔离。
2208
+ cmd += [
2209
+ "--setting-sources",
2210
+ "",
2211
+ "--no-session-persistence",
2212
+ "--strict-mcp-config",
2213
+ "--tools",
2214
+ *self.QODER_READ_ANALYZE_BUILTIN_TOOLS,
2215
+ ]
2103
2216
  return cmd
2104
2217
 
2105
2218
  cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
@@ -973,13 +973,23 @@ def mcp_bridge(
973
973
  ),
974
974
  ),
975
975
  profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
976
+ tool_profile: str = typer.Option(
977
+ "full",
978
+ "--tool-profile",
979
+ help="MCP capability profile (full or read_analyze).",
980
+ ),
976
981
  ) -> None:
977
982
  """[internal] stdio MCP server invoked by claude subprocess."""
978
983
  from .mcp_bridge import run_bridge
979
984
 
980
985
  profile_name = _resolve_cli_profile(profile)
981
986
  asyncio.run(
982
- run_bridge(agent_id=agent_id, token_file=token_file, profile=profile_name)
987
+ run_bridge(
988
+ agent_id=agent_id,
989
+ token_file=token_file,
990
+ profile=profile_name,
991
+ tool_profile=tool_profile,
992
+ )
983
993
  )
984
994
 
985
995
 
@@ -637,6 +637,7 @@ async def _run_sse_post(
637
637
  # IPC op instead of reading a file; file fallback kept for backward
638
638
  # compat (old daemons without this map still work via the file).
639
639
  _agent_to_session: dict[str, str | None] = {}
640
+ _agent_to_delivery: dict[str, str | None] = {}
640
641
 
641
642
  # M7 W3 — localagent context injection + delegation reporting.
642
643
  # ``injection_state`` is the file-backed (agent,task) dedup store
@@ -657,6 +658,7 @@ async def _run_sse_post(
657
658
  partial_publisher=_publish_partial,
658
659
  event_publisher=_publish_event,
659
660
  agent_to_session=_agent_to_session,
661
+ agent_to_delivery=_agent_to_delivery,
660
662
  claude_permission_mode=cfg.claude_permission_mode,
661
663
  runtime_binaries=runtime_binaries,
662
664
  http=http,
@@ -752,11 +754,17 @@ async def _run_sse_post(
752
754
  a = req.get("args") or {}
753
755
  agent_id_q = a.get("agent_id") or ""
754
756
  sid = _agent_to_session.get(agent_id_q)
757
+ delivery_id = _agent_to_delivery.get(agent_id_q)
755
758
  logger.info(
756
- "main_loop.ipc_handler: get_current_session agent=%s sid=%s",
757
- agent_id_q, sid,
759
+ "main_loop.ipc_handler: get_current_session agent=%s sid=%s delivery=%s",
760
+ agent_id_q, sid, delivery_id,
758
761
  )
759
- return {"result": {"session_id": sid}}
762
+ return {
763
+ "result": {
764
+ "session_id": sid,
765
+ "delivery_id": delivery_id,
766
+ }
767
+ }
760
768
 
761
769
  if op != "tool_call":
762
770
  logger.warning(
@@ -981,6 +989,8 @@ async def _run_legacy_ws(
981
989
  delegation_outbox = DelegationOutbox(
982
990
  profile_delegation_outbox_path(profile)
983
991
  )
992
+ agent_to_session_ws: dict[str, str | None] = {}
993
+ agent_to_delivery_ws: dict[str, str | None] = {}
984
994
 
985
995
  runner = ClaudeRunner(
986
996
  outbound=outbound,
@@ -991,6 +1001,8 @@ async def _run_legacy_ws(
991
1001
  http=ws_http,
992
1002
  injection_state=injection_state,
993
1003
  delegation_outbox=delegation_outbox,
1004
+ agent_to_session=agent_to_session_ws,
1005
+ agent_to_delivery=agent_to_delivery_ws,
994
1006
  )
995
1007
 
996
1008
  # #25 — pre-flight auth self-check (parity with the SSE path). Detached
@@ -1071,6 +1083,15 @@ async def _run_legacy_ws(
1071
1083
  sock_path.parent.mkdir(parents=True, exist_ok=True)
1072
1084
 
1073
1085
  async def ipc_handler(req: dict) -> dict:
1086
+ if req.get("op") == "get_current_session":
1087
+ a = req.get("args") or {}
1088
+ agent_id_q = a.get("agent_id") or ""
1089
+ return {
1090
+ "result": {
1091
+ "session_id": agent_to_session_ws.get(agent_id_q),
1092
+ "delivery_id": agent_to_delivery_ws.get(agent_id_q),
1093
+ }
1094
+ }
1074
1095
  if req.get("op") != "tool_call":
1075
1096
  return {"error": {"code": "UNKNOWN_OP", "message": str(req.get("op"))}}
1076
1097
  a = req.get("args") or {}