cloudagent-daemon 0.3.1__tar.gz → 0.4.0__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.3.1 → cloudagent_daemon-0.4.0}/.gitignore +14 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/PKG-INFO +1 -1
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/pyproject.toml +1 -1
- cloudagent_daemon-0.4.0/src/cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon-0.4.0/src/cloudagent_daemon/agent_upload.py +201 -0
- cloudagent_daemon-0.4.0/src/cloudagent_daemon/agent_upload_denylist.py +84 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/claude_runner.py +790 -51
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/context_fetch.py +27 -6
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/frame_dispatch.py +24 -7
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/http_outbound.py +36 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/main_loop.py +65 -13
- cloudagent_daemon-0.4.0/src/cloudagent_daemon/mcp_bridge.py +851 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/paths.py +59 -0
- cloudagent_daemon-0.4.0/src/cloudagent_daemon/readonly_bash_guard.py +140 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/runtime_detect.py +75 -9
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/workspace.py +10 -8
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_mcp_bridge_round_trip.py +94 -40
- cloudagent_daemon-0.4.0/tests/unit/test_agent_upload.py +185 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner.py +517 -64
- cloudagent_daemon-0.4.0/tests/unit/test_claude_runner_codex_contract.py +352 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner_routing_contract.py +20 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_frame_dispatch.py +44 -9
- cloudagent_daemon-0.4.0/tests/unit/test_mcp_bridge_agent_retrieval.py +125 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_mcp_bridge_send_message.py +17 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_paths.py +58 -0
- cloudagent_daemon-0.4.0/tests/unit/test_readonly_bash_guard.py +86 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_runtime_detect.py +35 -19
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_workspace.py +5 -1
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/uv.lock +2 -2
- cloudagent_daemon-0.3.1/src/cloudagent_daemon/__init__.py +0 -1
- cloudagent_daemon-0.3.1/src/cloudagent_daemon/mcp_bridge.py +0 -441
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/LICENSE +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/README.md +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/service.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_e2e_sse.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_full_e2e.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_one_shot_cli.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_cli_smoke.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_doctor.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_main_loop_assign.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_outbound_queue.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_service.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_spawn_queue.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_workspace_root_task113.py +0 -0
- {cloudagent_daemon-0.3.1 → cloudagent_daemon-0.4.0}/tests/unit/test_ws_client_insecure.py +0 -0
|
@@ -49,6 +49,9 @@ CLAUDE.md
|
|
|
49
49
|
# Git worktrees (local-only, isolated feature branches)
|
|
50
50
|
.worktrees/
|
|
51
51
|
|
|
52
|
+
# Local code-graph tooling DB (understand-anything / codegraph, ~60MB, not shared)
|
|
53
|
+
.codegraph/
|
|
54
|
+
|
|
52
55
|
# Local agent scratch output (UI checker screenshots, design data dumps, etc.)
|
|
53
56
|
.r2c/
|
|
54
57
|
one-company-fe/deploy
|
|
@@ -86,9 +89,20 @@ package-lock.json
|
|
|
86
89
|
|
|
87
90
|
# playwright 测试相关
|
|
88
91
|
.playwright-mcp/
|
|
92
|
+
tests/taoguang/
|
|
93
|
+
# fr live sanity 临时 Playwright 脚本 (Alex review 时 drop, 本地用)
|
|
94
|
+
one-company-fe/fr-sanity*.js
|
|
89
95
|
|
|
90
96
|
# M7 设计 demo 的解压产物(源在 Opc.zip,解压即得)
|
|
91
97
|
company/m7/refactors/Opc_extracted/
|
|
92
98
|
|
|
93
99
|
# understand-anything tool cache
|
|
94
100
|
.understand-anything/
|
|
101
|
+
|
|
102
|
+
# cyber-room-android
|
|
103
|
+
cyber-room-android/.gradle/
|
|
104
|
+
cyber-room-android/build/
|
|
105
|
+
cyber-room-android/app/build/
|
|
106
|
+
cyber-room-android/local.properties
|
|
107
|
+
cyber-room-android/qa/evidence/
|
|
108
|
+
cyber-room-android/qa/.secrets/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
|
|
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.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Local-agent file upload read site (task #9 刀3) — runs in **daemon-main**.
|
|
2
|
+
|
|
3
|
+
The local agent's ``upload_file(path)`` MCP tool (mcp_bridge) only forwards the
|
|
4
|
+
path over IPC — it NEVER reads the file. The actual read happens HERE, in the
|
|
5
|
+
daemon-main process, because only this process has the user's Mac filesystem
|
|
6
|
+
(the backend can't read Mac paths). After the read-site gate passes we hand the
|
|
7
|
+
bytes to the backend's ``/v1/daemon/agent-file-upload`` (刀1) via multipart with
|
|
8
|
+
the resolved realpath + declared size + a cross-check sha.
|
|
9
|
+
|
|
10
|
+
Read-site gate (Alex 刀3): exactly ONE explicit file — no directory / glob /
|
|
11
|
+
recursion; ``os.path.realpath`` (resolves symlinks); the shared home denylist
|
|
12
|
+
(byte-for-byte copy of the backend module); ``stat`` size BEFORE reading bytes;
|
|
13
|
+
bounded read (cap+1) in case the file grew between stat and open; the
|
|
14
|
+
client-computed sha is only a CROSS-CHECK (the backend recomputes the truth).
|
|
15
|
+
|
|
16
|
+
A read-site failure returns ``{"ok": False, ...}`` and NEVER calls the backend
|
|
17
|
+
and NEVER fabricates a success.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import logging
|
|
24
|
+
import mimetypes
|
|
25
|
+
import os
|
|
26
|
+
import stat
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from .agent_upload_denylist import is_denied
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_PATH_AGENT_UPLOAD = "/v1/daemon/agent-file-upload"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cap() -> int:
|
|
39
|
+
"""Per-file byte cap (read-site pre-check). The backend re-enforces its own
|
|
40
|
+
``settings.agent_upload_max_bytes`` authoritatively; this just rejects early
|
|
41
|
+
and clearly. Overridable via env for parity with the backend default."""
|
|
42
|
+
raw = os.environ.get("CLOUDAGENT_AGENT_UPLOAD_MAX_BYTES", "").strip()
|
|
43
|
+
try:
|
|
44
|
+
v = int(raw)
|
|
45
|
+
if v > 0:
|
|
46
|
+
return v
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
return 50 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _err(code: str, message: str) -> dict[str, Any]:
|
|
53
|
+
return {"ok": False, "error": {"code": code, "message": message}}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _backend_error_message(resp: httpx.Response) -> str:
|
|
57
|
+
"""Pull the agent-readable message out of the backend's ``{"error":{...}}``."""
|
|
58
|
+
try:
|
|
59
|
+
body = resp.json()
|
|
60
|
+
except ValueError:
|
|
61
|
+
return f"upload rejected (backend {resp.status_code})"
|
|
62
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
63
|
+
if isinstance(err, dict):
|
|
64
|
+
if err.get("message"):
|
|
65
|
+
return str(err["message"])
|
|
66
|
+
if err.get("code"):
|
|
67
|
+
return str(err["code"])
|
|
68
|
+
return f"upload rejected (backend {resp.status_code})"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def handle_upload_file(
|
|
72
|
+
http: Any,
|
|
73
|
+
*,
|
|
74
|
+
agent_id: str,
|
|
75
|
+
session_id: str | None,
|
|
76
|
+
args: dict[str, Any],
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Read the LOCAL file (all FS checks here) → multipart to the backend.
|
|
79
|
+
|
|
80
|
+
Returns ``{"ok": True, "data": {...}}`` / ``{"ok": False, "error": {...}}``
|
|
81
|
+
matching the ``send_tool_call`` shape the ipc_handler expects.
|
|
82
|
+
"""
|
|
83
|
+
path = args.get("path")
|
|
84
|
+
if not isinstance(path, str) or not path.strip():
|
|
85
|
+
return _err(
|
|
86
|
+
"INVALID_ARGS",
|
|
87
|
+
"upload_file: 'path' must be a non-empty path to ONE file",
|
|
88
|
+
)
|
|
89
|
+
raw = path.strip()
|
|
90
|
+
|
|
91
|
+
# ONE explicit file. Expand ~, realpath (resolves symlinks so a link into a
|
|
92
|
+
# denied dir is caught by its real target).
|
|
93
|
+
rp = os.path.realpath(os.path.expanduser(raw))
|
|
94
|
+
try:
|
|
95
|
+
st = os.stat(rp)
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
return _err(
|
|
98
|
+
"NOT_FOUND",
|
|
99
|
+
f"upload_file: cannot access {raw!r} ({exc.strerror or exc}). "
|
|
100
|
+
"Pass the path to an existing file.",
|
|
101
|
+
)
|
|
102
|
+
if stat.S_ISDIR(st.st_mode):
|
|
103
|
+
return _err(
|
|
104
|
+
"INVALID_ARGS",
|
|
105
|
+
"upload_file: that path is a directory. Pass ONE explicit file "
|
|
106
|
+
"— directories, globs and recursion are not supported.",
|
|
107
|
+
)
|
|
108
|
+
if not stat.S_ISREG(st.st_mode):
|
|
109
|
+
return _err(
|
|
110
|
+
"INVALID_ARGS",
|
|
111
|
+
"upload_file: that path is not a regular file (e.g. a device, "
|
|
112
|
+
"socket or fifo).",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Home-relative + segment denylist (authoritative — symlinks already
|
|
116
|
+
# resolved). Hit → hard failure, no "rename & retry" hint.
|
|
117
|
+
denied, reason = is_denied(rp, home=os.path.expanduser("~"))
|
|
118
|
+
if denied:
|
|
119
|
+
return _err(
|
|
120
|
+
"DENIED",
|
|
121
|
+
f"upload_file: {raw!r} is blocked for security ({reason}). Files "
|
|
122
|
+
"under credential/secret locations (e.g. ~/.ssh, ~/.aws) cannot be "
|
|
123
|
+
"uploaded.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Size guard BEFORE reading the bytes (stat-based).
|
|
127
|
+
cap = _cap()
|
|
128
|
+
size = st.st_size
|
|
129
|
+
if size == 0:
|
|
130
|
+
return _err("EMPTY", "upload_file: that file is empty.")
|
|
131
|
+
if size > cap:
|
|
132
|
+
return _err(
|
|
133
|
+
"TOO_LARGE",
|
|
134
|
+
f"upload_file: that file is too large ({size} bytes; the limit is "
|
|
135
|
+
f"{cap} bytes).",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Read bytes — size already capped; bounded read in case the file grew
|
|
139
|
+
# between stat and open.
|
|
140
|
+
try:
|
|
141
|
+
with open(rp, "rb") as fh:
|
|
142
|
+
content = fh.read(cap + 1)
|
|
143
|
+
except OSError as exc:
|
|
144
|
+
return _err(
|
|
145
|
+
"READ_FAILED",
|
|
146
|
+
f"upload_file: cannot read {raw!r} ({exc.strerror or exc}).",
|
|
147
|
+
)
|
|
148
|
+
if len(content) == 0:
|
|
149
|
+
return _err("EMPTY", "upload_file: that file is empty.")
|
|
150
|
+
if len(content) > cap:
|
|
151
|
+
return _err(
|
|
152
|
+
"TOO_LARGE",
|
|
153
|
+
f"upload_file: that file is too large (over {cap} bytes).",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
sha = hashlib.sha256(content).hexdigest()
|
|
157
|
+
fname = os.path.basename(rp)
|
|
158
|
+
mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
|
159
|
+
|
|
160
|
+
# cr0624 #51 — 平台会话(频道/DM)上传:透传 conversation_id,后端改走
|
|
161
|
+
# 平台分支(成员校验 + 自动贴附件消息)。无值 = 老 task 路径,行为不变。
|
|
162
|
+
conv_ref = args.get("conversation_id")
|
|
163
|
+
form: dict[str, str] = {
|
|
164
|
+
"agent_id": agent_id,
|
|
165
|
+
"session_id": session_id or "",
|
|
166
|
+
"client_realpath": rp,
|
|
167
|
+
"declared_size": str(size),
|
|
168
|
+
"content_sha256": sha,
|
|
169
|
+
}
|
|
170
|
+
if isinstance(conv_ref, str) and conv_ref.strip().startswith("conv_"):
|
|
171
|
+
form["conversation_id"] = conv_ref.strip()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
resp = await http.post_multipart(
|
|
175
|
+
_PATH_AGENT_UPLOAD,
|
|
176
|
+
files={"file": (fname, content, mime)},
|
|
177
|
+
data=form,
|
|
178
|
+
)
|
|
179
|
+
except httpx.HTTPStatusError as exc:
|
|
180
|
+
return _err("UPLOAD_REJECTED", f"upload_file: {_backend_error_message(exc.response)}")
|
|
181
|
+
except Exception as exc: # noqa: BLE001
|
|
182
|
+
logger.warning("agent_upload: backend call failed: %s", exc, exc_info=True)
|
|
183
|
+
return _err(
|
|
184
|
+
"UPLOAD_FAILED",
|
|
185
|
+
f"upload_file: upload failed ({type(exc).__name__}). Please retry.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
name = resp.get("name") or fname
|
|
189
|
+
download_path = resp.get("download_path")
|
|
190
|
+
placed = "频道" if "conversation_id" in form else "协作室"
|
|
191
|
+
return {
|
|
192
|
+
"ok": True,
|
|
193
|
+
"data": {
|
|
194
|
+
"file_id": resp.get("file_id"),
|
|
195
|
+
"filename": name,
|
|
196
|
+
"download_path": download_path,
|
|
197
|
+
"message": (
|
|
198
|
+
f"已上传「{name}」,并已在{placed}贴出附件消息:{download_path}"
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared denylist for the agent file-upload feature (任意路径 + 私密目录 denylist).
|
|
2
|
+
|
|
3
|
+
金山 authorised agent uploads from any explicit file path; Alex's gate (82340720 /
|
|
4
|
+
b41f2cbb) requires a denylist of common credential/secret locations, checked
|
|
5
|
+
**after realpath** so a symlink pointing into a denied directory is caught by its
|
|
6
|
+
real target. A hit is a HARD failure — never hint "rename and retry".
|
|
7
|
+
|
|
8
|
+
The AUTHORITATIVE check runs at the read site (daemon-main for local agents, the
|
|
9
|
+
pod for cloud agents) where the real home dir + filesystem exist: it does
|
|
10
|
+
``rp = os.path.realpath(path)`` then ``is_denied(rp, home)``. The backend keeps
|
|
11
|
+
this same module (one source of truth) and runs the home-independent
|
|
12
|
+
segment/basename check on the client-supplied realpath as defence-in-depth before
|
|
13
|
+
storing it in the audit record — a buggy or out-of-date client cannot smuggle a
|
|
14
|
+
``~/.ssh/id_ed25519`` past the server.
|
|
15
|
+
|
|
16
|
+
This module is pure (no FS/network) so both runtimes + the backend share it and it
|
|
17
|
+
is unit-testable in isolation. The daemon package keeps a byte-for-byte copy
|
|
18
|
+
(separate Python package); keep the two in sync.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
# Home-relative paths (dirs or files) that must never be uploaded. Checked as an
|
|
26
|
+
# absolute prefix/equal once the caller's home dir is known (the authoritative
|
|
27
|
+
# read-site check). Covers SSH/cloud/k8s/registry creds, GPG, shell rc secrets,
|
|
28
|
+
# the OS keychain, editor secret stores, and the daemon's own config dir.
|
|
29
|
+
DENIED_HOME_RELATIVE: tuple[str, ...] = (
|
|
30
|
+
".ssh",
|
|
31
|
+
".aws",
|
|
32
|
+
".config/gcloud",
|
|
33
|
+
".config/gh",
|
|
34
|
+
".kube",
|
|
35
|
+
".docker",
|
|
36
|
+
".gnupg",
|
|
37
|
+
".cloudagent",
|
|
38
|
+
".npmrc",
|
|
39
|
+
".pypirc",
|
|
40
|
+
".netrc",
|
|
41
|
+
".git-credentials",
|
|
42
|
+
"Library/Keychains",
|
|
43
|
+
"Library/Application Support/Code/User/globalStorage",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Home-independent defence-in-depth: any path *segment* equal to one of these is
|
|
47
|
+
# denied regardless of where home is (catches creds dirs nested anywhere).
|
|
48
|
+
_DENIED_SEGMENTS: frozenset[str] = frozenset(
|
|
49
|
+
{".ssh", ".aws", ".kube", ".docker", ".gnupg", ".cloudagent", "gcloud", "Keychains", "globalStorage"}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Home-independent: any file whose basename matches is denied wherever it lives.
|
|
53
|
+
_DENIED_BASENAMES: frozenset[str] = frozenset(
|
|
54
|
+
{".npmrc", ".pypirc", ".netrc", ".git-credentials"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_denied(realpath: str, home: str | None = None) -> tuple[bool, str | None]:
|
|
59
|
+
"""Return ``(True, reason)`` if ``realpath`` is on the denylist, else ``(False, None)``.
|
|
60
|
+
|
|
61
|
+
``realpath`` MUST already be ``os.path.realpath``-resolved by the caller (so a
|
|
62
|
+
symlink into a denied dir is resolved to its real, denied target before this
|
|
63
|
+
runs). ``home`` enables the authoritative home-relative prefix check at the
|
|
64
|
+
read site; omit it on the backend defence-in-depth pass (segment/basename only).
|
|
65
|
+
"""
|
|
66
|
+
norm = os.path.normpath(realpath)
|
|
67
|
+
base = os.path.basename(norm)
|
|
68
|
+
|
|
69
|
+
if base in _DENIED_BASENAMES:
|
|
70
|
+
return True, f"denied file: {base}"
|
|
71
|
+
|
|
72
|
+
segments = norm.split(os.sep)
|
|
73
|
+
for seg in segments:
|
|
74
|
+
if seg in _DENIED_SEGMENTS:
|
|
75
|
+
return True, f"denied path segment: {seg}"
|
|
76
|
+
|
|
77
|
+
if home:
|
|
78
|
+
home_norm = os.path.normpath(home)
|
|
79
|
+
for rel in DENIED_HOME_RELATIVE:
|
|
80
|
+
denied_abs = os.path.normpath(os.path.join(home_norm, rel))
|
|
81
|
+
if norm == denied_abs or norm.startswith(denied_abs + os.sep):
|
|
82
|
+
return True, f"denied path: ~/{rel}"
|
|
83
|
+
|
|
84
|
+
return False, None
|