cloudagent-daemon 0.4.1__tar.gz → 0.5.1__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.
- cloudagent_daemon-0.5.1/CHANGELOG.md +102 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/PKG-INFO +55 -17
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/README.md +50 -12
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/pyproject.toml +9 -4
- cloudagent_daemon-0.5.1/scripts/release.sh +128 -0
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/agent_download.py +131 -0
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/auth_preflight.py +170 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/claude_runner.py +282 -7
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/cli.py +160 -2
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/doctor.py +134 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/frame_dispatch.py +34 -2
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/http_outbound.py +14 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/main_loop.py +161 -13
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/mcp_bridge.py +119 -7
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/outbound_queue.py +3 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/paths.py +3 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/protocol.py +9 -0
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/qoder_credentials.py +175 -0
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/runtime_config.py +78 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/runtime_detect.py +1 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/service.py +203 -2
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/spawn_queue.py +15 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_e2e_deliver_to_spawn.py +7 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_e2e_sse.py +6 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_full_e2e.py +6 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_mcp_bridge_round_trip.py +66 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_one_shot_cli.py +5 -0
- cloudagent_daemon-0.5.1/tests/unit/test_agent_download.py +141 -0
- cloudagent_daemon-0.5.1/tests/unit/test_auth_preflight.py +188 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner.py +443 -2
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_cli_smoke.py +30 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_doctor.py +150 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_frame_dispatch.py +40 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_main_loop_assign.py +24 -0
- cloudagent_daemon-0.5.1/tests/unit/test_main_loop_preflight.py +68 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_mcp_bridge_agent_retrieval.py +76 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_outbound_queue.py +11 -0
- cloudagent_daemon-0.5.1/tests/unit/test_packaging_mcp_pin.py +33 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_paths.py +7 -0
- cloudagent_daemon-0.5.1/tests/unit/test_qoder_credentials.py +65 -0
- cloudagent_daemon-0.5.1/tests/unit/test_service.py +782 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_spawn_queue.py +22 -0
- cloudagent_daemon-0.5.1/tests/unit/test_start_guard.py +42 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/uv.lock +2 -2
- cloudagent_daemon-0.4.1/src/cloudagent_daemon/__init__.py +0 -1
- cloudagent_daemon-0.4.1/tests/unit/test_service.py +0 -308
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/.gitignore +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/LICENSE +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/agent_upload.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/context_fetch.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_agent_upload.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_codex_contract.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_routing_contract.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_mcp_bridge_send_message.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_runtime_detect.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_workspace.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_workspace_root_task113.py +0 -0
- {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.1}/tests/unit/test_ws_client_insecure.py +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Changelog — cloudagent-daemon
|
|
2
|
+
|
|
3
|
+
All notable changes to the local daemon. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/); versions are
|
|
5
|
+
[PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
|
|
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
|
+
|
|
38
|
+
## [0.5.0] — 2026-08-19
|
|
39
|
+
|
|
40
|
+
First release since 0.4.1 (2026-07-22). Everything below shipped to internal
|
|
41
|
+
hosts from source; this is the first time it reaches the PyPI package.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- **No-source installation and release tooling** (#96): a repeatable,
|
|
45
|
+
fail-closed PyPI release script, end-user installation guide, and
|
|
46
|
+
`cloudagent-daemon --version` / `-V` for verifying installed builds.
|
|
47
|
+
- **Qoder CLI runtime engine** (`runtime_engine=qoder`, #86/#87): `qodercli`
|
|
48
|
+
auto-detected, headless spawn via `-p --output-format stream-json`, model
|
|
49
|
+
and reasoning-effort passthrough, login probe in the periodic health loop.
|
|
50
|
+
`cloudagent-daemon setup-qoder-token` stores the Personal Access Token in
|
|
51
|
+
the macOS login Keychain (Linux: 0600 file) and injects it only into the
|
|
52
|
+
Qoder child process — never argv / plist / config / logs.
|
|
53
|
+
- **Login-session install** (#25): `cloudagent-daemon install-service` now
|
|
54
|
+
installs the daemon as a `gui/$UID` LaunchAgent (macOS) / user systemd unit
|
|
55
|
+
(Linux) so spawned CLIs can read the login keychain; boot-time auth
|
|
56
|
+
pre-flight logs an actionable remedy instead of a silent crash loop.
|
|
57
|
+
- **Periodic auth health → heartbeat** (#53): every 5 min the daemon probes
|
|
58
|
+
CLI login state and uplinks `runtime_health` with the heartbeat; the
|
|
59
|
+
backend refuses to dispatch to an agent whose runtime is logged out
|
|
60
|
+
(`STAFF_UNAVAILABLE`) instead of crash-quarantining it.
|
|
61
|
+
- **Attachments reach agents** (#48): `download_attachment` MCP tool + wake
|
|
62
|
+
body `【附件】` block; agents can fetch and read images/files shared in chat.
|
|
63
|
+
- **Real reminders** (#49): `schedule_reminder` / `list_reminders` /
|
|
64
|
+
`cancel_reminder` MCP tools (one-shot and cron, 5-minute floor) that truly
|
|
65
|
+
re-wake the agent.
|
|
66
|
+
- Per-agent runtime config at creation (model, reasoning effort, runtime
|
|
67
|
+
env) threaded through assign/identity to the spawn (#22).
|
|
68
|
+
- `acknowledge_new_messages` forwarded on `platform_send` (#28).
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
- **Qoder per-conversation session lanes** (#95): Qoder runtime sessions are
|
|
72
|
+
keyed by `conversation_id` so DM/#channel wakes never resume each other's
|
|
73
|
+
session; the per-channel system prompt (#88) is carried in the stdin
|
|
74
|
+
preamble every turn (qodercli `--resume` did not reliably adopt a new
|
|
75
|
+
`--append-system-prompt`).
|
|
76
|
+
- An `ack` POST failure could swallow a deliver's spawn (message lost until
|
|
77
|
+
the 120-min watchdog); ack and spawn are now independently contained.
|
|
78
|
+
- `mcp` pinned `>=1.9,<2` — mcp 2.x removed FastMCP and broke a fresh
|
|
79
|
+
`uv tool install` (#28).
|
|
80
|
+
- Agent working state no longer flickers idle while conversations are queued.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
- Runtime engines advertised to the backend: `claude_code`, `codex`, `qoder`.
|
|
84
|
+
- Qoder `read_analyze` (read-only) wakes are refused (fail-closed) until the
|
|
85
|
+
tool-restriction flags are verified on a real login.
|
|
86
|
+
|
|
87
|
+
## [0.4.1] — 2026-07-22
|
|
88
|
+
### Fixed
|
|
89
|
+
- Runtime detection: a probe that exits non-zero is reported as version
|
|
90
|
+
`unknown` instead of parsing its error text as a version (a broken nvm
|
|
91
|
+
shim no longer outranks a working install).
|
|
92
|
+
|
|
93
|
+
## [0.4.0] — 2026-07
|
|
94
|
+
- Multi-runtime detection across PATH + well-known dirs, newest version wins.
|
|
95
|
+
- Daemon profiles, SSE+POST transport default, spawn outbox / watchdog.
|
|
96
|
+
|
|
97
|
+
## [0.3.x] — 2026-05/06
|
|
98
|
+
- M7 W3: local-agent context injection, delegation token/status uplink.
|
|
99
|
+
- Token-level streaming of replies, per-agent `workspace_root`.
|
|
100
|
+
|
|
101
|
+
## [0.2.x] — 2026-05
|
|
102
|
+
- Initial public releases: spawn-on-message daemon, embedded MCP bridge.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Local daemon for cloudagent — bridges Claude Code
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent/docs/superpowers/specs
|
|
8
8
|
Author-email: chaoyin <tudoucyx@gmail.com>
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: ai-coding-agent,claude-code,cloudagent,daemon
|
|
11
|
+
Keywords: ai-coding-agent,claude-code,cloudagent,codex,daemon,qoder
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Operating System :: MacOS
|
|
14
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.12
|
|
|
20
20
|
Requires-Dist: h2>=4
|
|
21
21
|
Requires-Dist: httpx-sse>=0.4
|
|
22
22
|
Requires-Dist: httpx>=0.27
|
|
23
|
-
Requires-Dist: mcp
|
|
23
|
+
Requires-Dist: mcp<2,>=1.9
|
|
24
24
|
Requires-Dist: pydantic>=2.7
|
|
25
25
|
Requires-Dist: typer>=0.12
|
|
26
26
|
Requires-Dist: websockets>=12
|
|
@@ -32,19 +32,23 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
# cloudagent-daemon
|
|
34
34
|
|
|
35
|
-
Local daemon for cloudagent — runs on user's machine
|
|
36
|
-
|
|
37
|
-
your machine's filesystem and tools.
|
|
35
|
+
Local daemon for cloudagent — runs on the user's machine and bridges AI coding
|
|
36
|
+
CLIs (**Claude Code**, **Codex**, **Qoder CLI**) to the cloudagent backend so
|
|
37
|
+
agents can work locally with your machine's filesystem and tools.
|
|
38
38
|
|
|
39
|
-
**Architecture:** spawn-on-message
|
|
40
|
-
|
|
41
|
-
(
|
|
42
|
-
|
|
39
|
+
**Architecture:** spawn-on-message — the daemon keeps an SSE stream to the
|
|
40
|
+
backend, receives a `deliver` frame per wake, spawns the agent's CLI
|
|
41
|
+
(`claude` / `codex` / `qodercli`, resuming that agent's session), and the CLI
|
|
42
|
+
talks back through an MCP bridge embedded in this daemon (`platform_send`,
|
|
43
|
+
`send_message`, `download_attachment`, reminders…) before exiting.
|
|
43
44
|
|
|
44
|
-
## Quick start (no clone)
|
|
45
|
+
## Quick start (no clone) — for end users
|
|
45
46
|
|
|
46
47
|
Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
|
|
47
|
-
|
|
48
|
+
The repository also contains a step-by-step Chinese walkthrough at
|
|
49
|
+
`docs/daemon-install-guide.md`. Requirements: Python 3.12+ (`uv` will fetch one if missing),
|
|
50
|
+
[`uv`](https://docs.astral.sh/uv/), and at least one agent CLI on PATH and
|
|
51
|
+
logged in — `claude` (Claude Code), `codex`, or `qodercli`.
|
|
48
52
|
|
|
49
53
|
```bash
|
|
50
54
|
# One-shot run (no persistent install)
|
|
@@ -52,8 +56,10 @@ uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key s
|
|
|
52
56
|
```
|
|
53
57
|
|
|
54
58
|
```bash
|
|
55
|
-
# Persistent install —
|
|
56
|
-
uv tool install cloudagent-daemon
|
|
59
|
+
# Persistent install — save the machine identity, then install the service
|
|
60
|
+
uv tool install cloudagent-daemon
|
|
61
|
+
cloudagent-daemon login --server https://api.cloudagent.chat # paste the key at the hidden prompt
|
|
62
|
+
cloudagent-daemon install-service
|
|
57
63
|
```
|
|
58
64
|
|
|
59
65
|
```bash
|
|
@@ -61,10 +67,23 @@ uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https:
|
|
|
61
67
|
pipx install cloudagent-daemon
|
|
62
68
|
```
|
|
63
69
|
|
|
70
|
+
`login` stores the machine identity in a mode-0600 local config file;
|
|
71
|
+
`install-service` then starts a macOS `gui/$UID` LaunchAgent or Linux user
|
|
72
|
+
systemd unit from that config. Do not substitute a one-shot `start
|
|
73
|
+
--server-url ... --api-key ...` here: one-shot mode intentionally persists no
|
|
74
|
+
credentials, so a later argument-free service would start unauthenticated.
|
|
75
|
+
|
|
76
|
+
Qoder CLI agents need a Personal Access Token stored once per machine
|
|
77
|
+
(Keychain on macOS, 0600 file on Linux; never written to plist/config/logs):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cloudagent-daemon setup-qoder-token
|
|
81
|
+
```
|
|
82
|
+
|
|
64
83
|
To upgrade later:
|
|
65
84
|
|
|
66
85
|
```bash
|
|
67
|
-
uv tool upgrade cloudagent-daemon
|
|
86
|
+
uv tool upgrade cloudagent-daemon && cloudagent-daemon install-service # re-install picks up the new binary
|
|
68
87
|
# or
|
|
69
88
|
pipx upgrade cloudagent-daemon
|
|
70
89
|
```
|
|
@@ -133,6 +152,8 @@ cloudagent-daemon start \
|
|
|
133
152
|
# Output:
|
|
134
153
|
# Detected runtimes:
|
|
135
154
|
# ✓ claude_code 2.1.137 (/opt/homebrew/bin/claude)
|
|
155
|
+
# ✓ codex 0.145.0 (/opt/homebrew/bin/codex)
|
|
156
|
+
# ✓ qoder 1.1.25 (/Users/me/.local/bin/qodercli)
|
|
136
157
|
# Connecting to https://api.cloudagent.chat as host host_01HXX...
|
|
137
158
|
```
|
|
138
159
|
|
|
@@ -168,6 +189,7 @@ no state behind. For persistent setups, use `login` once + `start` after.
|
|
|
168
189
|
| `cloudagent-daemon stop` | Send SIGTERM, wait for clean exit |
|
|
169
190
|
| `cloudagent-daemon install-service` | Install + start as launchd / systemd user service (auto-restart on crash, auto-start at login) |
|
|
170
191
|
| `cloudagent-daemon uninstall-service` | Stop + remove the launchd / systemd service unit |
|
|
192
|
+
| `cloudagent-daemon setup-qoder-token` | Store the Qoder CLI Personal Access Token securely (Keychain / 0600 file) for Qoder agents |
|
|
171
193
|
| `cloudagent-daemon agent register --agent-id ID` | Create local workspace |
|
|
172
194
|
| `cloudagent-daemon agent unregister --agent-id ID` | Archive workspace |
|
|
173
195
|
|
|
@@ -309,3 +331,19 @@ See `docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md`.
|
|
|
309
331
|
## License
|
|
310
332
|
|
|
311
333
|
MIT — see [LICENSE](./LICENSE).
|
|
334
|
+
|
|
335
|
+
## Releasing (maintainers)
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
# 1. bump version in pyproject.toml AND src/cloudagent_daemon/__init__.py (must match)
|
|
339
|
+
# 2. add a "## [x.y.z]" section to CHANGELOG.md
|
|
340
|
+
# 3. commit, then:
|
|
341
|
+
UV_PUBLISH_TOKEN=pypi-... scripts/release.sh # or scripts/release.sh --dry-run
|
|
342
|
+
git push cloudagent daemon-v<x.y.z>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The script refuses to publish on a dirty tree, a version/lockfile mismatch, a
|
|
346
|
+
missing changelog entry, an ambiguous PyPI response, an existing version, or a
|
|
347
|
+
conflicting tag. It runs the unit suite with a scrubbed environment, builds,
|
|
348
|
+
and validates both distribution artifacts first. The token is read from the
|
|
349
|
+
environment only — never commit it.
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# cloudagent-daemon
|
|
2
2
|
|
|
3
|
-
Local daemon for cloudagent — runs on user's machine
|
|
4
|
-
|
|
5
|
-
your machine's filesystem and tools.
|
|
3
|
+
Local daemon for cloudagent — runs on the user's machine and bridges AI coding
|
|
4
|
+
CLIs (**Claude Code**, **Codex**, **Qoder CLI**) to the cloudagent backend so
|
|
5
|
+
agents can work locally with your machine's filesystem and tools.
|
|
6
6
|
|
|
7
|
-
**Architecture:** spawn-on-message
|
|
8
|
-
|
|
9
|
-
(
|
|
10
|
-
|
|
7
|
+
**Architecture:** spawn-on-message — the daemon keeps an SSE stream to the
|
|
8
|
+
backend, receives a `deliver` frame per wake, spawns the agent's CLI
|
|
9
|
+
(`claude` / `codex` / `qodercli`, resuming that agent's session), and the CLI
|
|
10
|
+
talks back through an MCP bridge embedded in this daemon (`platform_send`,
|
|
11
|
+
`send_message`, `download_attachment`, reminders…) before exiting.
|
|
11
12
|
|
|
12
|
-
## Quick start (no clone)
|
|
13
|
+
## Quick start (no clone) — for end users
|
|
13
14
|
|
|
14
15
|
Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
|
|
15
|
-
|
|
16
|
+
The repository also contains a step-by-step Chinese walkthrough at
|
|
17
|
+
`docs/daemon-install-guide.md`. Requirements: Python 3.12+ (`uv` will fetch one if missing),
|
|
18
|
+
[`uv`](https://docs.astral.sh/uv/), and at least one agent CLI on PATH and
|
|
19
|
+
logged in — `claude` (Claude Code), `codex`, or `qodercli`.
|
|
16
20
|
|
|
17
21
|
```bash
|
|
18
22
|
# One-shot run (no persistent install)
|
|
@@ -20,8 +24,10 @@ uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key s
|
|
|
20
24
|
```
|
|
21
25
|
|
|
22
26
|
```bash
|
|
23
|
-
# Persistent install —
|
|
24
|
-
uv tool install cloudagent-daemon
|
|
27
|
+
# Persistent install — save the machine identity, then install the service
|
|
28
|
+
uv tool install cloudagent-daemon
|
|
29
|
+
cloudagent-daemon login --server https://api.cloudagent.chat # paste the key at the hidden prompt
|
|
30
|
+
cloudagent-daemon install-service
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
```bash
|
|
@@ -29,10 +35,23 @@ uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https:
|
|
|
29
35
|
pipx install cloudagent-daemon
|
|
30
36
|
```
|
|
31
37
|
|
|
38
|
+
`login` stores the machine identity in a mode-0600 local config file;
|
|
39
|
+
`install-service` then starts a macOS `gui/$UID` LaunchAgent or Linux user
|
|
40
|
+
systemd unit from that config. Do not substitute a one-shot `start
|
|
41
|
+
--server-url ... --api-key ...` here: one-shot mode intentionally persists no
|
|
42
|
+
credentials, so a later argument-free service would start unauthenticated.
|
|
43
|
+
|
|
44
|
+
Qoder CLI agents need a Personal Access Token stored once per machine
|
|
45
|
+
(Keychain on macOS, 0600 file on Linux; never written to plist/config/logs):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cloudagent-daemon setup-qoder-token
|
|
49
|
+
```
|
|
50
|
+
|
|
32
51
|
To upgrade later:
|
|
33
52
|
|
|
34
53
|
```bash
|
|
35
|
-
uv tool upgrade cloudagent-daemon
|
|
54
|
+
uv tool upgrade cloudagent-daemon && cloudagent-daemon install-service # re-install picks up the new binary
|
|
36
55
|
# or
|
|
37
56
|
pipx upgrade cloudagent-daemon
|
|
38
57
|
```
|
|
@@ -101,6 +120,8 @@ cloudagent-daemon start \
|
|
|
101
120
|
# Output:
|
|
102
121
|
# Detected runtimes:
|
|
103
122
|
# ✓ claude_code 2.1.137 (/opt/homebrew/bin/claude)
|
|
123
|
+
# ✓ codex 0.145.0 (/opt/homebrew/bin/codex)
|
|
124
|
+
# ✓ qoder 1.1.25 (/Users/me/.local/bin/qodercli)
|
|
104
125
|
# Connecting to https://api.cloudagent.chat as host host_01HXX...
|
|
105
126
|
```
|
|
106
127
|
|
|
@@ -136,6 +157,7 @@ no state behind. For persistent setups, use `login` once + `start` after.
|
|
|
136
157
|
| `cloudagent-daemon stop` | Send SIGTERM, wait for clean exit |
|
|
137
158
|
| `cloudagent-daemon install-service` | Install + start as launchd / systemd user service (auto-restart on crash, auto-start at login) |
|
|
138
159
|
| `cloudagent-daemon uninstall-service` | Stop + remove the launchd / systemd service unit |
|
|
160
|
+
| `cloudagent-daemon setup-qoder-token` | Store the Qoder CLI Personal Access Token securely (Keychain / 0600 file) for Qoder agents |
|
|
139
161
|
| `cloudagent-daemon agent register --agent-id ID` | Create local workspace |
|
|
140
162
|
| `cloudagent-daemon agent unregister --agent-id ID` | Archive workspace |
|
|
141
163
|
|
|
@@ -277,3 +299,19 @@ See `docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md`.
|
|
|
277
299
|
## License
|
|
278
300
|
|
|
279
301
|
MIT — see [LICENSE](./LICENSE).
|
|
302
|
+
|
|
303
|
+
## Releasing (maintainers)
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
# 1. bump version in pyproject.toml AND src/cloudagent_daemon/__init__.py (must match)
|
|
307
|
+
# 2. add a "## [x.y.z]" section to CHANGELOG.md
|
|
308
|
+
# 3. commit, then:
|
|
309
|
+
UV_PUBLISH_TOKEN=pypi-... scripts/release.sh # or scripts/release.sh --dry-run
|
|
310
|
+
git push cloudagent daemon-v<x.y.z>
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The script refuses to publish on a dirty tree, a version/lockfile mismatch, a
|
|
314
|
+
missing changelog entry, an ambiguous PyPI response, an existing version, or a
|
|
315
|
+
conflicting tag. It runs the unit suite with a scrubbed environment, builds,
|
|
316
|
+
and validates both distribution artifacts first. The token is read from the
|
|
317
|
+
environment only — never commit it.
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudagent-daemon"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Local daemon for cloudagent — bridges Claude Code
|
|
7
|
+
version = "0.5.1"
|
|
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"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
keywords = ["cloudagent", "claude-code", "daemon", "ai-coding-agent"]
|
|
12
|
+
keywords = ["cloudagent", "claude-code", "codex", "qoder", "daemon", "ai-coding-agent"]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Development Status :: 4 - Beta",
|
|
15
15
|
"Programming Language :: Python :: 3.12",
|
|
@@ -33,7 +33,12 @@ dependencies = [
|
|
|
33
33
|
# ``:hb`` / deliver events flow as intended. Public endpoints are
|
|
34
34
|
# unaffected (and h2 just downgrades to 1.1 if upstream rejects it).
|
|
35
35
|
"h2>=4",
|
|
36
|
-
|
|
36
|
+
# #28 — pin BELOW 2.0: the bridge imports ``mcp.server.fastmcp`` (FastMCP),
|
|
37
|
+
# which mcp 2.x removed/relocated. An unpinned ``mcp>=0.9`` let a fresh
|
|
38
|
+
# uv-tool install resolve mcp==2.0.0 → ``ModuleNotFoundError`` at
|
|
39
|
+
# mcp-bridge startup (Cris's tools dead until a manual downgrade). Keep it
|
|
40
|
+
# on the 1.x line that ships FastMCP.
|
|
41
|
+
"mcp>=1.9,<2",
|
|
37
42
|
]
|
|
38
43
|
|
|
39
44
|
[project.urls]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cloudagent-daemon release — repeatable PyPI publish (#96).
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# UV_PUBLISH_TOKEN=pypi-xxx scripts/release.sh # build + test + publish
|
|
6
|
+
# scripts/release.sh --dry-run # everything except upload
|
|
7
|
+
# scripts/release.sh --skip-tests # (not recommended)
|
|
8
|
+
#
|
|
9
|
+
# What it enforces, in order:
|
|
10
|
+
# 1. clean git tree inside daemon_cli/ (no half-committed release)
|
|
11
|
+
# 2. pyproject.toml version == cloudagent_daemon.__version__
|
|
12
|
+
# 3. uv.lock is current and CHANGELOG.md has a section for that version
|
|
13
|
+
# 4. PyPI is reachable and the version is not already published
|
|
14
|
+
# 5. the release tag, if already present, points at the current commit
|
|
15
|
+
# 6. unit tests pass (set CLOUDAGENT_DAEMON_PROFILE unset — host env pollution
|
|
16
|
+
# is the known cause of the sandbox-only failures; we run with a scrubbed env)
|
|
17
|
+
# 7. uv build (sdist + wheel) into a fresh dist/ and validate both artifacts
|
|
18
|
+
# 8. uv publish (token from UV_PUBLISH_TOKEN; never stored in the repo)
|
|
19
|
+
# 9. git tag daemon-v<version> (local; push is left to the operator)
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
DRY_RUN=0
|
|
23
|
+
SKIP_TESTS=0
|
|
24
|
+
for arg in "$@"; do
|
|
25
|
+
case "$arg" in
|
|
26
|
+
--dry-run) DRY_RUN=1 ;;
|
|
27
|
+
--skip-tests) SKIP_TESTS=1 ;;
|
|
28
|
+
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
|
29
|
+
*) echo "unknown arg: $arg" >&2; exit 2 ;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
|
34
|
+
cd "$HERE"
|
|
35
|
+
|
|
36
|
+
command -v uv >/dev/null || { echo "uv not found — install: curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
|
|
37
|
+
|
|
38
|
+
# 1. clean tree (daemon_cli only — the monorepo may have unrelated WIP)
|
|
39
|
+
if [ -n "$(git status --porcelain -- . )" ]; then
|
|
40
|
+
echo "refusing to release with uncommitted changes under daemon_cli/:" >&2
|
|
41
|
+
git status --short -- . >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# 2. version consistency
|
|
46
|
+
PYPROJECT_VER="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -1)"
|
|
47
|
+
INIT_VER="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/cloudagent_daemon/__init__.py | head -1)"
|
|
48
|
+
if [ -z "$PYPROJECT_VER" ] || [ "$PYPROJECT_VER" != "$INIT_VER" ]; then
|
|
49
|
+
echo "version mismatch: pyproject=$PYPROJECT_VER __init__=$INIT_VER" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
VER="$PYPROJECT_VER"
|
|
53
|
+
TAG="daemon-v$VER"
|
|
54
|
+
echo "== releasing cloudagent-daemon $VER"
|
|
55
|
+
|
|
56
|
+
# 3. lockfile current + changelog section present. A version bump changes the
|
|
57
|
+
# editable root package entry in uv.lock; without this check `uv run` below
|
|
58
|
+
# silently rewrites the tracked lockfile after the one-time clean-tree check.
|
|
59
|
+
if ! uv lock --check; then
|
|
60
|
+
echo "uv.lock is stale — run 'uv lock' and commit the result before releasing" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
if ! grep -q "^## \[$VER\]" CHANGELOG.md; then
|
|
64
|
+
echo "CHANGELOG.md has no '## [$VER]' section — write the release notes first" >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# 4. PyPI reachable + version absent. Only an explicit 404 means it is safe
|
|
69
|
+
# to continue; DNS/TLS/timeouts/5xx must fail closed rather than being mistaken
|
|
70
|
+
# for "version does not exist".
|
|
71
|
+
if ! PYPI_STATUS="$(curl -sS -m 15 -o /dev/null -w '%{http_code}' \
|
|
72
|
+
"https://pypi.org/pypi/cloudagent-daemon/$VER/json")"; then
|
|
73
|
+
echo "could not verify cloudagent-daemon $VER on PyPI — refusing to publish" >&2
|
|
74
|
+
exit 1
|
|
75
|
+
fi
|
|
76
|
+
case "$PYPI_STATUS" in
|
|
77
|
+
200)
|
|
78
|
+
echo "cloudagent-daemon $VER already exists on PyPI — bump the version" >&2
|
|
79
|
+
exit 1
|
|
80
|
+
;;
|
|
81
|
+
404) ;;
|
|
82
|
+
*)
|
|
83
|
+
echo "PyPI version check returned HTTP $PYPI_STATUS — refusing to publish" >&2
|
|
84
|
+
exit 1
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
|
|
88
|
+
# 5. Never publish a commit whose release tag names a different commit. An
|
|
89
|
+
# existing same-commit tag is allowed so a failed upload can be retried safely.
|
|
90
|
+
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
|
91
|
+
if [ "$(git rev-list -n 1 "$TAG")" != "$(git rev-parse HEAD)" ]; then
|
|
92
|
+
echo "$TAG already exists but does not point at HEAD — refusing to publish" >&2
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
echo "== existing release tag $TAG already points at HEAD"
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# 6. tests (scrubbed env: profile/server vars from the operator's shell must not leak)
|
|
99
|
+
if [ "$SKIP_TESTS" = "0" ]; then
|
|
100
|
+
echo "== unit tests"
|
|
101
|
+
env -u CLOUDAGENT_DAEMON_PROFILE -u CLOUDAGENT_SERVER_URL -u CLOUDAGENT_API_KEY \
|
|
102
|
+
uv run --extra dev python -m pytest tests/unit -q -x -p no:cacheprovider
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# 7. build + metadata validation
|
|
106
|
+
echo "== build"
|
|
107
|
+
rm -rf dist
|
|
108
|
+
uv build
|
|
109
|
+
ls -l dist/
|
|
110
|
+
uvx --from twine twine check dist/*
|
|
111
|
+
|
|
112
|
+
# 8. publish
|
|
113
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
114
|
+
echo "== dry-run: skipping upload. Artifacts in dist/ — inspect with:"
|
|
115
|
+
echo " uv run --with twine twine check dist/*"
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
: "${UV_PUBLISH_TOKEN:?set UV_PUBLISH_TOKEN=pypi-... (project-scoped token recommended; never commit it)}"
|
|
119
|
+
echo "== publish"
|
|
120
|
+
uv publish dist/*
|
|
121
|
+
|
|
122
|
+
# 9. tag. Do not swallow tag failures after a successful upload.
|
|
123
|
+
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
|
124
|
+
git tag -a "$TAG" -m "cloudagent-daemon $VER"
|
|
125
|
+
fi
|
|
126
|
+
echo "== done: https://pypi.org/project/cloudagent-daemon/$VER/"
|
|
127
|
+
echo " push the tag: git push cloudagent $TAG"
|
|
128
|
+
echo " verify fresh install: uvx --refresh cloudagent-daemon@$VER --version"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.1"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""#48 — ``download_attachment``: fetch a platform attachment for a local agent.
|
|
2
|
+
|
|
3
|
+
The read twin of ``agent_upload``. The agent (claude) calls the bridge tool
|
|
4
|
+
with an ``attachment_id`` (from the wake body's 【附件】 block or a
|
|
5
|
+
``platform_read`` message's ``attachments``); daemon-main pulls the bytes from
|
|
6
|
+
``/v1/daemon/agent-file-download`` (machine-key auth; visibility enforced
|
|
7
|
+
server-side by the shared #51 gate) and lands them under the agent's OWN cwd
|
|
8
|
+
(``<cwd>/attachments/``) so the agent's Read tool can open the file — images
|
|
9
|
+
render multimodally, so "拿到图片" means literally seeing the picture.
|
|
10
|
+
|
|
11
|
+
Runs in daemon-main (like ``upload_file``): the backend dispatcher can't write
|
|
12
|
+
Mac paths, and the bytes must NOT ride the JSON tool-result channel.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import unquote
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_PATH_AGENT_DOWNLOAD = "/v1/daemon/agent-file-download"
|
|
26
|
+
|
|
27
|
+
# Windows-reserved + path-separator + control chars — enough to keep a hostile
|
|
28
|
+
# server-supplied filename inside the attachments dir on macOS/Linux.
|
|
29
|
+
_BAD_FILENAME_CHARS = re.compile(r"[\\/\0\r\n:*?\"<>|]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _err(code: str, message: str) -> dict[str, Any]:
|
|
33
|
+
return {"ok": False, "error": {"code": code, "message": message}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _filename_from_headers(headers: dict, fallback: str) -> str:
|
|
37
|
+
"""RFC 5987 ``filename*=UTF-8''…`` first, then plain ``filename=…``."""
|
|
38
|
+
cd = ""
|
|
39
|
+
for k, v in headers.items():
|
|
40
|
+
if str(k).lower() == "content-disposition":
|
|
41
|
+
cd = str(v)
|
|
42
|
+
break
|
|
43
|
+
m = re.search(r"filename\*=UTF-8''([^;]+)", cd)
|
|
44
|
+
if m:
|
|
45
|
+
name = unquote(m.group(1))
|
|
46
|
+
else:
|
|
47
|
+
m = re.search(r'filename="?([^";]+)"?', cd)
|
|
48
|
+
name = m.group(1) if m else fallback
|
|
49
|
+
# basename + character sanitation: never let a server-supplied name escape
|
|
50
|
+
# the attachments dir or smuggle separators.
|
|
51
|
+
name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
52
|
+
name = _BAD_FILENAME_CHARS.sub("_", name).strip().strip(".")
|
|
53
|
+
return name or fallback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def handle_download_attachment(
|
|
57
|
+
http: Any,
|
|
58
|
+
workspace: Any,
|
|
59
|
+
*,
|
|
60
|
+
agent_id: str,
|
|
61
|
+
session_id: str | None,
|
|
62
|
+
args: dict[str, Any],
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""Download ``args['attachment_id']`` into the agent's cwd; return the path."""
|
|
65
|
+
att_id = str(args.get("attachment_id") or "").strip()
|
|
66
|
+
if not att_id:
|
|
67
|
+
return _err("INVALID_ARGS", "attachment_id is required")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
data, headers = await http.get_bytes(
|
|
71
|
+
_PATH_AGENT_DOWNLOAD,
|
|
72
|
+
params={"agent_id": agent_id, "attachment_id": att_id},
|
|
73
|
+
)
|
|
74
|
+
except httpx.HTTPStatusError as exc:
|
|
75
|
+
status = exc.response.status_code
|
|
76
|
+
if status == 404:
|
|
77
|
+
return _err(
|
|
78
|
+
"NOT_FOUND",
|
|
79
|
+
"attachment not found or not visible to this agent "
|
|
80
|
+
"(需要你是该会话的成员,且消息未被删除)",
|
|
81
|
+
)
|
|
82
|
+
logger.warning(
|
|
83
|
+
"agent_download: backend %d for att=%s agent=%s",
|
|
84
|
+
status, att_id, agent_id,
|
|
85
|
+
)
|
|
86
|
+
return _err("DOWNLOAD_FAILED", f"backend returned HTTP {status}")
|
|
87
|
+
except Exception as exc: # noqa: BLE001
|
|
88
|
+
logger.warning(
|
|
89
|
+
"agent_download: fetch failed att=%s: %s", att_id, exc, exc_info=True
|
|
90
|
+
)
|
|
91
|
+
return _err("DOWNLOAD_FAILED", f"download failed: {type(exc).__name__}")
|
|
92
|
+
|
|
93
|
+
if not data:
|
|
94
|
+
return _err("EMPTY_FILE", "attachment has no bytes")
|
|
95
|
+
|
|
96
|
+
filename = _filename_from_headers(headers, fallback=att_id)
|
|
97
|
+
mime = ""
|
|
98
|
+
for k, v in headers.items():
|
|
99
|
+
if str(k).lower() == "content-type":
|
|
100
|
+
mime = str(v)
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
dest_dir = workspace.for_agent(agent_id).cwd / "attachments"
|
|
105
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
dest = dest_dir / filename
|
|
107
|
+
if dest.exists():
|
|
108
|
+
# Same name ≠ same file — prefix with the (unique) attachment id.
|
|
109
|
+
dest = dest_dir / f"{att_id[-8:]}-{filename}"
|
|
110
|
+
dest.write_bytes(data)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
logger.warning("agent_download: write failed att=%s: %s", att_id, exc)
|
|
113
|
+
return _err("WRITE_FAILED", f"could not write file: {exc}")
|
|
114
|
+
|
|
115
|
+
logger.info(
|
|
116
|
+
"agent_download: saved att=%s agent=%s → %s (%d bytes)",
|
|
117
|
+
att_id, agent_id, dest, len(data),
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"ok": True,
|
|
121
|
+
"data": {
|
|
122
|
+
"local_path": str(dest),
|
|
123
|
+
"filename": filename,
|
|
124
|
+
"mime_type": mime or "application/octet-stream",
|
|
125
|
+
"size_bytes": len(data),
|
|
126
|
+
"hint": "用 Read 工具打开 local_path 查看内容(图片可直接看到画面)",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["handle_download_attachment"]
|