tinet-agent-cli 0.1.0.dev36__py3-none-any.whl
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.
- taco/__init__.py +1 -0
- taco/agent_openapi/__init__.py +1 -0
- taco/agent_openapi/catalog.py +198 -0
- taco/agent_openapi/models.py +47 -0
- taco/agent_openapi/parameters.py +111 -0
- taco/agent_openapi/service.py +73 -0
- taco/aikb/__init__.py +4 -0
- taco/aikb/catalog.py +380 -0
- taco/aikb/executors.py +157 -0
- taco/aikb/file_input.py +55 -0
- taco/aikb/models.py +42 -0
- taco/aikb/parameters.py +135 -0
- taco/aikb/service.py +68 -0
- taco/assets/__init__.py +1 -0
- taco/assets/agent_openapi/catalog.json +14 -0
- taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
- taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
- taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
- taco/assets/aikb/catalog.json +41 -0
- taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
- taco/assets/aikb/operations/directory/delete-directory.json +81 -0
- taco/assets/aikb/operations/directory/edit-directory.json +97 -0
- taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
- taco/assets/aikb/operations/directory/save-directory.json +109 -0
- taco/assets/aikb/operations/faq/create-faq.json +193 -0
- taco/assets/aikb/operations/faq/delete-faq.json +81 -0
- taco/assets/aikb/operations/faq/describe-faq.json +82 -0
- taco/assets/aikb/operations/faq/edit-faq.json +193 -0
- taco/assets/aikb/operations/faq/list-faqs.json +136 -0
- taco/assets/aikb/operations/file/create-file.json +145 -0
- taco/assets/aikb/operations/file/delete-files.json +110 -0
- taco/assets/aikb/operations/file/describe-file.json +82 -0
- taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
- taco/assets/aikb/operations/file/list-files.json +146 -0
- taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
- taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
- taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
- taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
- taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
- taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
- taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
- taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
- taco/assets/examples/README.md +3 -0
- taco/assets/examples/agent-basic.json +16 -0
- taco/assets/examples/agent-builtin-tool.json +28 -0
- taco/assets/examples/agent-workflow-tool.json +30 -0
- taco/assets/examples/chatflow-basic.json +24 -0
- taco/assets/examples/workflow-http.json +34 -0
- taco/assets/manifest.json +12 -0
- taco/assets/scenarios/README.md +3 -0
- taco/assets/scenarios/agent-basic.json +80 -0
- taco/assets/scenarios/agent-builtin-tool.json +116 -0
- taco/assets/scenarios/agent-workflow-tool.json +277 -0
- taco/assets/schemas/README.md +3 -0
- taco/assets/schemas/agent-tool.json +71 -0
- taco/assets/schemas/agent.json +199 -0
- taco/assets/schemas/chatflow.json +1048 -0
- taco/assets/schemas/workflow.http.json +1052 -0
- taco/assets/schemas/workflow.json +1052 -0
- taco/assets/skills/README.md +3 -0
- taco/assets/skills/taco/SKILL.md +68 -0
- taco/assets/skills/taco/manifest.json +10 -0
- taco/cli.py +127 -0
- taco/commands/__init__.py +1 -0
- taco/commands/agent.py +760 -0
- taco/commands/aikb.py +132 -0
- taco/commands/app.py +151 -0
- taco/commands/category.py +37 -0
- taco/commands/chatflow.py +183 -0
- taco/commands/credential.py +222 -0
- taco/commands/discovery.py +409 -0
- taco/commands/model.py +55 -0
- taco/commands/profile.py +124 -0
- taco/commands/tool.py +61 -0
- taco/commands/workflow.py +714 -0
- taco/core/__init__.py +1 -0
- taco/core/access_token_manager.py +98 -0
- taco/core/api_client.py +263 -0
- taco/core/assets.py +81 -0
- taco/core/config_store.py +209 -0
- taco/core/context.py +19 -0
- taco/core/developer_center_client.py +165 -0
- taco/core/errors.py +19 -0
- taco/core/file_lock.py +80 -0
- taco/core/http_headers.py +21 -0
- taco/core/openapi_signer.py +221 -0
- taco/core/output.py +72 -0
- taco/core/profile_manager.py +217 -0
- taco/core/profile_resolver.py +151 -0
- taco/core/secure_file.py +76 -0
- taco/release.py +363 -0
- taco/services/__init__.py +1 -0
- taco/services/agent_service.py +314 -0
- taco/services/app_service.py +153 -0
- taco/services/category_service.py +57 -0
- taco/services/model_service.py +117 -0
- taco/services/tool_service.py +482 -0
- taco/services/workflow_compiler.py +1665 -0
- taco/services/workflow_service.py +652 -0
- taco/services/workflow_tool_service.py +439 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from taco.core.access_token_manager import credential_fingerprint
|
|
7
|
+
from taco.core.config_store import ConfigStore, TacoConfig
|
|
8
|
+
from taco.core.errors import TacoError
|
|
9
|
+
from taco.core.output import mask_token
|
|
10
|
+
from taco.core.profile_resolver import ProfileResolver, ResolvedProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProfileManager:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
store: ConfigStore | None = None,
|
|
17
|
+
*,
|
|
18
|
+
resolver: ProfileResolver | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.store = store or ConfigStore()
|
|
21
|
+
self.resolver = resolver or ProfileResolver(self.store)
|
|
22
|
+
|
|
23
|
+
def set_profile(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
name: str,
|
|
27
|
+
endpoint: str,
|
|
28
|
+
access_key_id: str,
|
|
29
|
+
access_key_secret: str,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
normalized_name = name.strip()
|
|
32
|
+
normalized_endpoint = endpoint.strip().rstrip("/")
|
|
33
|
+
normalized_access_key_id = access_key_id.strip()
|
|
34
|
+
if not normalized_name:
|
|
35
|
+
raise TacoError(code="PROFILE_NAME_REQUIRED", message="profile name 不能为空。", exit_code=2)
|
|
36
|
+
parsed = urlsplit(normalized_endpoint)
|
|
37
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
38
|
+
raise TacoError(code="PROFILE_ENDPOINT_INVALID", message="endpoint 不是有效 HTTP(S) 地址。", exit_code=2)
|
|
39
|
+
if not normalized_access_key_id or not access_key_secret:
|
|
40
|
+
raise TacoError(code="ACCESS_KEY_PROFILE_INVALID", message="AccessKey profile 配置不完整。", exit_code=2)
|
|
41
|
+
profile_data = {
|
|
42
|
+
"endpoint": normalized_endpoint,
|
|
43
|
+
"access_key_id": normalized_access_key_id,
|
|
44
|
+
"access_key_secret": access_key_secret,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def mutate(config: TacoConfig) -> None:
|
|
48
|
+
if config.profiles.get(normalized_name) != profile_data:
|
|
49
|
+
config.auth_tokens.pop(normalized_name, None)
|
|
50
|
+
config.profiles[normalized_name] = profile_data
|
|
51
|
+
config.current_profile = normalized_name
|
|
52
|
+
|
|
53
|
+
self.store.update(mutate)
|
|
54
|
+
return {
|
|
55
|
+
"profile": normalized_name,
|
|
56
|
+
"current": True,
|
|
57
|
+
"endpoint": normalized_endpoint,
|
|
58
|
+
"access_key_id": normalized_access_key_id,
|
|
59
|
+
"access_key_secret": "********",
|
|
60
|
+
"has_access_token": False,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def list_profiles(self) -> list[dict[str, Any]]:
|
|
64
|
+
config = self.store.load()
|
|
65
|
+
entries: list[tuple[str, str, dict[str, Any], bool]] = []
|
|
66
|
+
zenava = self.resolver.load_zenava()
|
|
67
|
+
if zenava is not None:
|
|
68
|
+
entries.extend(
|
|
69
|
+
(
|
|
70
|
+
name,
|
|
71
|
+
"zenava",
|
|
72
|
+
value,
|
|
73
|
+
name == zenava["current_profile"],
|
|
74
|
+
)
|
|
75
|
+
for name, value in zenava["profiles"].items()
|
|
76
|
+
)
|
|
77
|
+
entries.extend(
|
|
78
|
+
(
|
|
79
|
+
name,
|
|
80
|
+
"taco",
|
|
81
|
+
value,
|
|
82
|
+
name == config.current_profile,
|
|
83
|
+
)
|
|
84
|
+
for name, value in config.profiles.items()
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
name_counts: dict[str, int] = {}
|
|
88
|
+
for name, _, _, _ in entries:
|
|
89
|
+
name_counts[name] = name_counts.get(name, 0) + 1
|
|
90
|
+
|
|
91
|
+
profiles: list[dict[str, Any]] = []
|
|
92
|
+
for name, source, value, current in entries:
|
|
93
|
+
token = _string(value.get("token") or value.get("access_token"))
|
|
94
|
+
token_updated_at = _string(
|
|
95
|
+
value.get("token_updated_at") or value.get("access_token_updated_at")
|
|
96
|
+
)
|
|
97
|
+
cached = config.auth_tokens.get(name, {})
|
|
98
|
+
if _cache_belongs_to_profile(
|
|
99
|
+
name,
|
|
100
|
+
source,
|
|
101
|
+
value,
|
|
102
|
+
cached,
|
|
103
|
+
name_is_unique=name_counts[name] == 1,
|
|
104
|
+
):
|
|
105
|
+
token = _string(cached.get("access_token")) or token
|
|
106
|
+
token_updated_at = _string(cached.get("updated_at")) or token_updated_at
|
|
107
|
+
profiles.append(
|
|
108
|
+
self._public_profile(
|
|
109
|
+
name,
|
|
110
|
+
value,
|
|
111
|
+
source=source,
|
|
112
|
+
current=current,
|
|
113
|
+
token=token,
|
|
114
|
+
token_updated_at=token_updated_at,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
source_order = {"zenava": 0, "taco": 1}
|
|
119
|
+
return sorted(
|
|
120
|
+
profiles,
|
|
121
|
+
key=lambda item: (
|
|
122
|
+
not item["current"],
|
|
123
|
+
source_order[item["source"]],
|
|
124
|
+
item["key"],
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def use(self, name: str) -> dict[str, str]:
|
|
129
|
+
def mutate(config: TacoConfig) -> None:
|
|
130
|
+
if name not in config.profiles:
|
|
131
|
+
raise _profile_not_found_error(name)
|
|
132
|
+
config.current_profile = name
|
|
133
|
+
|
|
134
|
+
self.store.update(mutate)
|
|
135
|
+
return {"current_profile": name}
|
|
136
|
+
|
|
137
|
+
def delete(self, name: str) -> dict[str, str | None]:
|
|
138
|
+
current: list[str | None] = []
|
|
139
|
+
|
|
140
|
+
def mutate(config: TacoConfig) -> None:
|
|
141
|
+
if name not in config.profiles:
|
|
142
|
+
raise _profile_not_found_error(name)
|
|
143
|
+
if name == config.current_profile:
|
|
144
|
+
raise TacoError(
|
|
145
|
+
code="PROFILE_DELETE_CURRENT_FORBIDDEN",
|
|
146
|
+
message=f"不能删除当前 profile `{name}`。",
|
|
147
|
+
exit_code=2,
|
|
148
|
+
hint="请先执行 taco profile use 切换到其他 profile。",
|
|
149
|
+
)
|
|
150
|
+
del config.profiles[name]
|
|
151
|
+
config.auth_tokens.pop(name, None)
|
|
152
|
+
current.append(config.current_profile)
|
|
153
|
+
|
|
154
|
+
self.store.update(mutate)
|
|
155
|
+
return {"deleted": name, "current_profile": current[0]}
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _public_profile(
|
|
159
|
+
name: str,
|
|
160
|
+
value: dict[str, Any],
|
|
161
|
+
*,
|
|
162
|
+
source: str = "taco",
|
|
163
|
+
current: bool,
|
|
164
|
+
token: str = "",
|
|
165
|
+
token_updated_at: str = "",
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
return {
|
|
168
|
+
"key": name,
|
|
169
|
+
"source": source,
|
|
170
|
+
"current": current,
|
|
171
|
+
"endpoint": value.get("endpoint", ""),
|
|
172
|
+
"access_key_id": value.get("access_key_id", ""),
|
|
173
|
+
"access_key_secret": "********" if value.get("access_key_secret") else "",
|
|
174
|
+
"access_token": mask_token(token),
|
|
175
|
+
"access_token_updated_at": token_updated_at,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _profile_not_found_error(name: str) -> TacoError:
|
|
180
|
+
return TacoError(
|
|
181
|
+
code="PROFILE_NOT_FOUND",
|
|
182
|
+
message=f"profile `{name}` 不存在。",
|
|
183
|
+
exit_code=2,
|
|
184
|
+
hint="请执行 taco profile list 查看可用 profile。",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _string(value: Any) -> str:
|
|
189
|
+
return value if isinstance(value, str) else ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cache_belongs_to_profile(
|
|
193
|
+
name: str,
|
|
194
|
+
source: str,
|
|
195
|
+
value: dict[str, Any],
|
|
196
|
+
cached: dict[str, Any],
|
|
197
|
+
*,
|
|
198
|
+
name_is_unique: bool,
|
|
199
|
+
) -> bool:
|
|
200
|
+
if not _string(cached.get("access_token")):
|
|
201
|
+
return False
|
|
202
|
+
fingerprint = _string(cached.get("credential_fingerprint"))
|
|
203
|
+
if not fingerprint:
|
|
204
|
+
return name_is_unique
|
|
205
|
+
endpoint = _string(value.get("endpoint")).strip().rstrip("/")
|
|
206
|
+
access_key_id = _string(value.get("access_key_id")).strip()
|
|
207
|
+
access_key_secret = _string(value.get("access_key_secret"))
|
|
208
|
+
if not endpoint or not access_key_id or not access_key_secret:
|
|
209
|
+
return False
|
|
210
|
+
profile = ResolvedProfile(
|
|
211
|
+
name=name,
|
|
212
|
+
source="zenava" if source == "zenava" else "taco",
|
|
213
|
+
endpoint=endpoint,
|
|
214
|
+
access_key_id=access_key_id,
|
|
215
|
+
access_key_secret=access_key_secret,
|
|
216
|
+
)
|
|
217
|
+
return fingerprint == credential_fingerprint(profile)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from json import JSONDecodeError
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from taco.core.config_store import ConfigStore
|
|
11
|
+
from taco.core.errors import TacoError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ResolvedProfile:
|
|
16
|
+
name: str
|
|
17
|
+
source: Literal["zenava", "taco"]
|
|
18
|
+
endpoint: str
|
|
19
|
+
access_key_id: str
|
|
20
|
+
access_key_secret: str
|
|
21
|
+
legacy_access_token: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProfileResolver:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
store: ConfigStore | None = None,
|
|
28
|
+
*,
|
|
29
|
+
zenava_path: Path | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.store = store or ConfigStore()
|
|
32
|
+
self.zenava_path = zenava_path or Path.home() / ".zenava" / "profile.json"
|
|
33
|
+
|
|
34
|
+
def resolve(self, name: str | None = None) -> ResolvedProfile:
|
|
35
|
+
if name is not None:
|
|
36
|
+
zenava = self._load_zenava()
|
|
37
|
+
if zenava is not None and name in zenava["profiles"]:
|
|
38
|
+
return _resolve_profile(
|
|
39
|
+
name,
|
|
40
|
+
zenava["profiles"][name],
|
|
41
|
+
source="zenava",
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
try:
|
|
45
|
+
zenava = self._load_zenava()
|
|
46
|
+
zenava_name = zenava["current_profile"] if zenava else None
|
|
47
|
+
if zenava_name is not None and zenava_name in zenava["profiles"]:
|
|
48
|
+
try:
|
|
49
|
+
return _resolve_profile(
|
|
50
|
+
zenava_name,
|
|
51
|
+
zenava["profiles"][zenava_name],
|
|
52
|
+
source="zenava",
|
|
53
|
+
)
|
|
54
|
+
except TacoError:
|
|
55
|
+
pass
|
|
56
|
+
except TacoError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
taco = self.store.load()
|
|
60
|
+
taco_name = name if name is not None else taco.current_profile
|
|
61
|
+
if taco_name is None or taco_name not in taco.profiles:
|
|
62
|
+
raise TacoError(
|
|
63
|
+
code="PROFILE_NOT_FOUND",
|
|
64
|
+
message=f"profile `{taco_name or ''}` 不存在。",
|
|
65
|
+
exit_code=2,
|
|
66
|
+
hint="请执行 taco profile set 创建配置,或使用 --profile 选择已有配置。",
|
|
67
|
+
)
|
|
68
|
+
return _resolve_profile(taco_name, taco.profiles[taco_name], source="taco")
|
|
69
|
+
|
|
70
|
+
def load_zenava(self) -> dict[str, Any] | None:
|
|
71
|
+
"""读取 Zenava profile 集合,供只读发现命令使用。"""
|
|
72
|
+
return self._load_zenava()
|
|
73
|
+
|
|
74
|
+
def known_names(self) -> set[str]:
|
|
75
|
+
names = set(self.store.load().profiles)
|
|
76
|
+
zenava = self._load_zenava()
|
|
77
|
+
if zenava is not None:
|
|
78
|
+
names.update(zenava["profiles"])
|
|
79
|
+
return names
|
|
80
|
+
|
|
81
|
+
def _load_zenava(self) -> dict[str, Any] | None:
|
|
82
|
+
if not self.zenava_path.is_file():
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(self.zenava_path.read_text(encoding="utf-8"))
|
|
86
|
+
except (OSError, JSONDecodeError) as error:
|
|
87
|
+
raise _zenava_invalid_error() from error
|
|
88
|
+
if not isinstance(payload, dict):
|
|
89
|
+
raise _zenava_invalid_error()
|
|
90
|
+
current_profile = payload.get("currentProfile")
|
|
91
|
+
profiles = payload.get("profiles", {})
|
|
92
|
+
if (
|
|
93
|
+
(current_profile is not None and not isinstance(current_profile, str))
|
|
94
|
+
or not isinstance(profiles, dict)
|
|
95
|
+
or any(
|
|
96
|
+
not isinstance(profile_name, str) or not isinstance(value, dict)
|
|
97
|
+
for profile_name, value in profiles.items()
|
|
98
|
+
)
|
|
99
|
+
):
|
|
100
|
+
raise _zenava_invalid_error()
|
|
101
|
+
return {"current_profile": current_profile, "profiles": profiles}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_profile(
|
|
105
|
+
name: str,
|
|
106
|
+
value: dict[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
source: Literal["zenava", "taco"],
|
|
109
|
+
) -> ResolvedProfile:
|
|
110
|
+
required = ("endpoint", "access_key_id", "access_key_secret")
|
|
111
|
+
missing = [
|
|
112
|
+
field
|
|
113
|
+
for field in required
|
|
114
|
+
if not isinstance(value.get(field), str) or not str(value[field]).strip()
|
|
115
|
+
]
|
|
116
|
+
if missing:
|
|
117
|
+
raise TacoError(
|
|
118
|
+
code="ACCESS_KEY_PROFILE_INVALID",
|
|
119
|
+
message=f"{source} profile `{name}` 缺少字段:{', '.join(missing)}。",
|
|
120
|
+
exit_code=2,
|
|
121
|
+
)
|
|
122
|
+
endpoint = str(value["endpoint"]).strip().rstrip("/")
|
|
123
|
+
parsed = urlsplit(endpoint)
|
|
124
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
125
|
+
raise TacoError(
|
|
126
|
+
code="ACCESS_KEY_PROFILE_INVALID",
|
|
127
|
+
message=f"{source} profile `{name}` 的 endpoint 不是有效 HTTP(S) 地址。",
|
|
128
|
+
exit_code=2,
|
|
129
|
+
)
|
|
130
|
+
legacy_access_token = value.get("access_token") if source == "taco" else None
|
|
131
|
+
return ResolvedProfile(
|
|
132
|
+
name=name,
|
|
133
|
+
source=source,
|
|
134
|
+
endpoint=endpoint,
|
|
135
|
+
access_key_id=str(value["access_key_id"]).strip(),
|
|
136
|
+
access_key_secret=str(value["access_key_secret"]),
|
|
137
|
+
legacy_access_token=(
|
|
138
|
+
legacy_access_token
|
|
139
|
+
if isinstance(legacy_access_token, str) and legacy_access_token
|
|
140
|
+
else None
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _zenava_invalid_error() -> TacoError:
|
|
146
|
+
return TacoError(
|
|
147
|
+
code="ZENAVA_CONFIG_INVALID",
|
|
148
|
+
message="Zenava profile 配置格式不合法。",
|
|
149
|
+
exit_code=2,
|
|
150
|
+
hint="请检查 ~/.zenava/profile.json。",
|
|
151
|
+
)
|
taco/core/secure_file.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SecureFileReadError(Exception):
|
|
10
|
+
def __init__(self, reason: str) -> None:
|
|
11
|
+
self.reason = reason
|
|
12
|
+
super().__init__(reason)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_regular_file(path: Path | str, *, max_bytes: int) -> bytes:
|
|
16
|
+
"""Read a bounded regular file without following path replacement races."""
|
|
17
|
+
source = Path(path)
|
|
18
|
+
try:
|
|
19
|
+
path_stat = os.lstat(source)
|
|
20
|
+
except OSError as error:
|
|
21
|
+
raise SecureFileReadError("unreadable") from error
|
|
22
|
+
if stat.S_ISLNK(path_stat.st_mode):
|
|
23
|
+
raise SecureFileReadError("symlink")
|
|
24
|
+
if not stat.S_ISREG(path_stat.st_mode):
|
|
25
|
+
raise SecureFileReadError("not_regular")
|
|
26
|
+
|
|
27
|
+
flags = os.O_RDONLY
|
|
28
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
29
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
30
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
31
|
+
try:
|
|
32
|
+
descriptor = os.open(source, flags)
|
|
33
|
+
except OSError as error:
|
|
34
|
+
if error.errno == errno.ELOOP:
|
|
35
|
+
reason = "symlink"
|
|
36
|
+
elif error.errno == errno.ENOENT:
|
|
37
|
+
reason = "changed"
|
|
38
|
+
else:
|
|
39
|
+
reason = "unreadable"
|
|
40
|
+
raise SecureFileReadError(reason) from error
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
opened_stat = os.fstat(descriptor)
|
|
44
|
+
if not stat.S_ISREG(opened_stat.st_mode):
|
|
45
|
+
raise SecureFileReadError("not_regular")
|
|
46
|
+
if (path_stat.st_dev, path_stat.st_ino) != (
|
|
47
|
+
opened_stat.st_dev,
|
|
48
|
+
opened_stat.st_ino,
|
|
49
|
+
):
|
|
50
|
+
raise SecureFileReadError("changed")
|
|
51
|
+
if opened_stat.st_size > max_bytes:
|
|
52
|
+
raise SecureFileReadError("too_large")
|
|
53
|
+
|
|
54
|
+
content = bytearray()
|
|
55
|
+
while len(content) <= max_bytes:
|
|
56
|
+
chunk = os.read(
|
|
57
|
+
descriptor,
|
|
58
|
+
min(64 * 1024, max_bytes + 1 - len(content)),
|
|
59
|
+
)
|
|
60
|
+
if not chunk:
|
|
61
|
+
break
|
|
62
|
+
content.extend(chunk)
|
|
63
|
+
if len(content) > max_bytes:
|
|
64
|
+
raise SecureFileReadError("too_large")
|
|
65
|
+
if os.fstat(descriptor).st_size > max_bytes:
|
|
66
|
+
raise SecureFileReadError("too_large")
|
|
67
|
+
return bytes(content)
|
|
68
|
+
except SecureFileReadError:
|
|
69
|
+
raise
|
|
70
|
+
except OSError as error:
|
|
71
|
+
raise SecureFileReadError("unreadable") from error
|
|
72
|
+
finally:
|
|
73
|
+
try:
|
|
74
|
+
os.close(descriptor)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|