@qingflow-tech/qingflow-cli 1.1.46 → 1.1.47-beta.2

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.
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
7
+
8
+ from .config import get_web_base_url
9
+ from .errors import QingflowApiError
10
+ from .json_types import JSONObject
11
+
12
+
13
+ SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
14
+ _ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ResourcePath:
19
+ resource_type: str
20
+ ws_id: int
21
+ package_id: int | None = None
22
+ app_key: str | None = None
23
+ view_kind: str | None = None
24
+ view_key: str | None = None
25
+
26
+
27
+ def validate_resource_identity(value: object, field: str = "resource_id") -> str:
28
+ """按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
29
+ return _validate_identity(value, field)
30
+
31
+
32
+ def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
33
+ """按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
34
+ return int(_positive_int_segment(value, field))
35
+
36
+
37
+ def build_resource_uri(
38
+ *,
39
+ ws_id: object,
40
+ resource_type: str,
41
+ package_id: object = None,
42
+ app_key: object = None,
43
+ view_kind: str | None = None,
44
+ view_key: object = None,
45
+ web_origin: str | None = None,
46
+ ) -> str:
47
+ """按固定公共合同构造资源 URI,不接受通用目标路由。"""
48
+ workspace = _positive_int_segment(ws_id, "ws_id")
49
+ if resource_type == "package":
50
+ path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
51
+ elif resource_type == "app":
52
+ path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
53
+ elif resource_type == "view":
54
+ app_segment = _identity_segment(app_key, "app_key")
55
+ if view_kind == "custom":
56
+ view_segment = _identity_segment(view_key, "view_key")
57
+ elif view_kind == "system":
58
+ name = str(view_key or "").strip()
59
+ if name not in SYSTEM_VIEW_NAMES:
60
+ raise ValueError("system view name is not supported")
61
+ view_segment = name
62
+ else:
63
+ raise ValueError("view_kind must be custom or system")
64
+ path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
65
+ else:
66
+ raise ValueError("resource_type is not supported")
67
+ return f"{_normalize_web_origin(web_origin) if web_origin is not None else get_web_base_url()}{path}"
68
+
69
+
70
+ def parse_resource_path(value: str) -> ResourcePath:
71
+ """严格解析资源 pathname,供免登录签发前校验工作区。"""
72
+ try:
73
+ parsed = urlsplit(value)
74
+ if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
75
+ raise ValueError
76
+ parts = parsed.path.split("/")
77
+ if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
78
+ raise ValueError
79
+ ws_id = int(_decode_segment(parts[3], "ws_id"))
80
+ if ws_id <= 0:
81
+ raise ValueError
82
+ if len(parts) == 6 and parts[4] == "package":
83
+ package_id = int(_decode_segment(parts[5], "package_id"))
84
+ if package_id <= 0:
85
+ raise ValueError
86
+ return ResourcePath("package", ws_id, package_id=package_id)
87
+ if len(parts) == 6 and parts[4] == "app":
88
+ return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
89
+ if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
90
+ app_key = _decode_segment(parts[5], "app_key")
91
+ view_kind = parts[7]
92
+ view_key = _decode_segment(parts[8], "view_key")
93
+ if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
94
+ raise ValueError
95
+ if view_kind not in {"custom", "system"}:
96
+ raise ValueError
97
+ return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
98
+ except (TypeError, ValueError, UnicodeError):
99
+ pass
100
+ raise ValueError("resource path is invalid")
101
+
102
+
103
+ def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
104
+ try:
105
+ return build_resource_uri(**kwargs), None
106
+ except (QingflowApiError, ValueError, UnicodeError) as error:
107
+ return None, {
108
+ "code": "RESOURCE_URI_UNAVAILABLE",
109
+ "message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
110
+ }
111
+
112
+
113
+ def _positive_int_segment(value: object, field: str) -> str:
114
+ if isinstance(value, bool):
115
+ raise ValueError(f"{field} must be a positive integer")
116
+ try:
117
+ parsed = int(value)
118
+ except (TypeError, ValueError):
119
+ raise ValueError(f"{field} must be a positive integer") from None
120
+ if parsed <= 0 or str(value).strip() != str(parsed):
121
+ raise ValueError(f"{field} must be a positive integer")
122
+ return str(parsed)
123
+
124
+
125
+ def _normalize_web_origin(value: str) -> str:
126
+ try:
127
+ parsed = urlsplit(value.strip())
128
+ if (
129
+ parsed.scheme not in {"http", "https"}
130
+ or not parsed.hostname
131
+ or parsed.username is not None
132
+ or parsed.password is not None
133
+ or parsed.path not in {"", "/"}
134
+ or parsed.query
135
+ or parsed.fragment
136
+ or any(character.isspace() or ord(character) < 32 for character in value)
137
+ ):
138
+ raise ValueError
139
+ _ = parsed.port
140
+ except (AttributeError, ValueError):
141
+ raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
142
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
143
+
144
+
145
+ def _identity_segment(value: object, field: str) -> str:
146
+ raw = _validate_identity(value, field)
147
+ return quote(raw, safe="")
148
+
149
+
150
+ def _decode_segment(value: str, field: str) -> str:
151
+ decoded = _validate_identity(unquote(value, errors="strict"), field)
152
+ if quote(decoded, safe="") != value:
153
+ raise ValueError(f"{field} must use canonical path encoding")
154
+ return decoded
155
+
156
+
157
+ def _validate_identity(value: object, field: str) -> str:
158
+ source = str(value or "")
159
+ if any(ord(character) < 32 or ord(character) == 127 for character in source):
160
+ raise ValueError(f"{field} must be one safe path segment")
161
+ raw = source.strip()
162
+ if not raw:
163
+ raise ValueError(f"{field} is required")
164
+ current = raw
165
+ for _ in range(8):
166
+ if (
167
+ current in {".", ".."}
168
+ or any(character in current for character in "/\\?#")
169
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
170
+ ):
171
+ raise ValueError(f"{field} must be one safe path segment")
172
+ decoded = unquote(current, errors="strict")
173
+ if decoded == current:
174
+ return raw
175
+ current = decoded
176
+ if (
177
+ current in {".", ".."}
178
+ or any(character in current for character in "/\\?#")
179
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
180
+ ):
181
+ raise ValueError(f"{field} must be one safe path segment")
182
+ if _ENCODED_OCTET.search(current):
183
+ raise ValueError(f"{field} has excessive encoding")
184
+ return raw
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
51
51
 
52
52
  SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
53
53
 
54
+ #: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
55
+ #: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
56
+ _KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
57
+
54
58
 
55
59
  def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
56
60
  if not isinstance(payload, dict):
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
85
89
  drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
86
90
  if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
87
91
  drop_keys = drop_keys - {"ok"}
92
+ if tool_name in _KEEP_OK_TOOLS:
93
+ drop_keys = drop_keys - {"ok"}
88
94
  if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
89
95
  drop_keys = drop_keys - {"suggested_next_call"}
90
96
  _drop_top_keys(trimmed, drop_keys)
@@ -320,7 +326,7 @@ def _trim_workspace_get(payload: JSONObject) -> None:
320
326
 
321
327
  def _trim_app_list_like(payload: JSONObject) -> None:
322
328
  payload.pop("apps", None)
323
- _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
329
+ _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name", "uri"))
324
330
 
325
331
 
326
332
  def _trim_app_get(payload: JSONObject) -> None:
@@ -328,7 +334,7 @@ def _trim_app_get(payload: JSONObject) -> None:
328
334
  _trim_nested_item_list(
329
335
  payload,
330
336
  ("data", "accessible_views"),
331
- allowed=("view_id", "name", "analysis_supported", "list_supported", "kind"),
337
+ allowed=("view_id", "name", "analysis_supported", "list_supported", "kind", "uri"),
332
338
  )
333
339
 
334
340
 
@@ -1128,6 +1134,13 @@ def _summarize_form_diff(diff: dict[str, Any]) -> dict[str, Any]:
1128
1134
  return summary
1129
1135
 
1130
1136
 
1137
+ def _trim_login_link(payload: JSONObject) -> None:
1138
+ # 临时票据只通过链接交付,必须保留链接和过期时间。
1139
+ for key in list(payload):
1140
+ if key not in {"url", "expires_at"}:
1141
+ payload.pop(key)
1142
+
1143
+
1131
1144
  def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform: TransformFn) -> None:
1132
1145
  for domain in domains:
1133
1146
  for name in names:
@@ -1209,6 +1222,7 @@ _register_policy(
1209
1222
  _trim_directory,
1210
1223
  )
1211
1224
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("feedback_submit",), _trim_feedback)
1225
+ _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("get_login_link",), _trim_login_link)
1212
1226
  _register_policy(
1213
1227
  (USER_DOMAIN,),
1214
1228
  (
@@ -13,6 +13,7 @@ from .tools.feedback_tools import FeedbackTools
13
13
  from .tools.export_tools import ExportTools
14
14
  from .tools.file_tools import FileTools
15
15
  from .tools.import_tools import ImportTools
16
+ from .tools.login_link_tools import LoginLinkTools
16
17
  from .tools.package_tools import PackageTools
17
18
  from .tools.navigation_tools import NavigationTools
18
19
  from .tools.directory_tools import DirectoryTools
@@ -238,6 +239,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
238
239
  sessions = SessionStore()
239
240
  backend = BackendClient()
240
241
  AuthTools(sessions, backend).register(server)
242
+ LoginLinkTools(sessions, backend).register(server)
241
243
  FeedbackTools(backend, mcp_side="通用").register(server)
242
244
  WorkspaceTools(sessions, backend).register(server)
243
245
  FileTools(sessions, backend).register(server)
@@ -9,6 +9,7 @@ from .config import DEFAULT_PROFILE
9
9
  from .response_trim import BUILDER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
10
10
  from .session_store import SessionStore
11
11
  from .tools.ai_builder_tools import AiBuilderTools
12
+ from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
12
13
  from .tools.auth_tools import AuthTools
13
14
  from .tools.feedback_tools import FeedbackTools
14
15
  from .tools.file_tools import FileTools
@@ -41,6 +42,7 @@ def build_builder_server() -> FastMCP:
41
42
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
42
43
  "then app_form_apply for complete app form targets, explicit app_delete for application deletion, and app_flow_apply/app_views_apply/app_associated_resources_apply/app_charts_apply/portal_apply/portal_delete for non-form patches. app_custom_buttons_apply is maintenance-only for explicit standalone button work outside normal view design. These apply/delete tools perform validation, normalization, writes, and readback verification where applicable. AppForm publishes only after a draft change or when the published form lags behind; views noop requests skip publish, app_custom_buttons_apply and app_associated_resources_apply publish after at least one write succeeds and expose no draft-only parameter, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, portal delete separates DELETE execution from readback verification, portal pc layout is a 24-column grid and mobile is a 6-column grid so omit position or use layout_preset when unsure, publish=false only guarantees draft/base-info updates for tools that still expose that parameter, and flow should use publish=false whenever you only want draft/precheck behavior. "
43
44
  "Builder apply/write outputs include schema_version, operation, summary, and resources[]; use resources[].id/key/name/ids/parent as the stable UI and agent display entry, and keep legacy fields such as field_diff/views_diff/chart_results only for compatibility or troubleshooting. "
45
+ "For page handoff, prefer stable resource uri values in read/apply outputs. Do not issue tickets for intermediate reads. If the target browser state is unknown, issue one fresh get_login_link ticket per delivered resource uri; only deliver the stable uri directly after the user confirms the same browser, web origin, account, and workspace are already logged in. A prior ticket, an automated browser session, or another resource ticket is not that evidence, and consumed tickets cannot be reused. Do not open a returned URL unless the user asks; if ticket issuance fails, state that login is required and provide the stable uri as the fallback. "
44
46
  "For existing object parameter replacement, prefer patch_views, patch_buttons, patch_resources, and patch_charts with set/unset; the tool reads current config and full-saves internally, while upsert_* is for creation or full target configuration and should not be used as an incomplete partial update. "
45
47
  "For builder delete/remove apply results, separate delete execution from readback verification: after DELETE is sent, resources expose delete_executed, readback_status, and safe_to_retry_delete=false. If readback_status is unavailable or still_exists, do not blindly repeat the delete; confirm later with app_get/view_get/chart_get or the relevant apply readback. Views/buttons use single-item readback; associated resources use one app-level resource-pool readback because there is no confirmed single-item GET. "
46
48
  "For builder write/apply results, separate write execution from final readback verification: status=partial_success with write_executed=true and safe_to_retry=false means the write was sent or succeeded, while final readback, publish verification, or metadata confirmation is pending or permission-restricted. Report it as written but unverified; do not repeat the same write blindly. "
@@ -64,6 +66,7 @@ def build_builder_server() -> FastMCP:
64
66
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
65
67
  files = wrap_trimmed_methods(FileTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
66
68
  ai_builder = wrap_trimmed_methods(AiBuilderTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
69
+ login_link = LoginLinkTools(sessions, backend)
67
70
  feedback = FeedbackTools(backend, mcp_side="App Builder MCP")
68
71
 
69
72
  @server.tool()
@@ -90,6 +93,10 @@ def build_builder_server() -> FastMCP:
90
93
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
91
94
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
92
95
 
96
+ @server.tool(description=LOGIN_LINK_DESCRIPTION)
97
+ def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
98
+ return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
99
+
93
100
  @server.tool()
94
101
  def workspace_list(
95
102
  profile: str = DEFAULT_PROFILE,
@@ -17,6 +17,7 @@ from .tools.export_tools import ExportTools
17
17
  from .tools.feedback_tools import FeedbackTools
18
18
  from .tools.file_tools import FileTools
19
19
  from .tools.import_tools import ImportTools
20
+ from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
20
21
  from .tools.resource_read_tools import ResourceReadTools
21
22
  from .tools.task_context_tools import TaskContextTools
22
23
  from .tools.workspace_tools import WorkspaceTools
@@ -30,6 +31,17 @@ def build_user_server(*, stateless_http: bool = False) -> FastMCP:
30
31
  stateless_http=stateless_http,
31
32
  instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
32
33
 
34
+ ## 页面交接
35
+
36
+ 用户需要查看结果、继续编辑、处理业务或进入工作区页面时,优先使用业务输出中的稳定 `uri`。纯数据查询和中间读取不签发链接。
37
+ 目标路径由业务工具或领域 Skill 根据真实前端路由确定,不由票据工具根据业务标识推导。
38
+ 已知目标传可选 `redirect_path`;明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默改为首页。
39
+ 搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;鉴权或工具失败时如实说明。
40
+ 目标浏览器登录态未知时,默认对每个要交付的资源 `uri` 分别调用一次 `get_login_link`;只有用户确认同一浏览器、网页域名、账号和工作区已经登录时才直接交付稳定 `uri`。调用过票据工具、自动化浏览器已登录或之前签发过票据均不能证明用户浏览器已登录,已消费链接也不能复用。
41
+ 临近交付时再签发,使用返回的完整 URL。要求“给我链接”时仅交付,不提前消费;要求“打开”时才调用浏览器。签发失败时明确说明需要登录,并提供对应稳定 `uri` 作为备用入口。
42
+ 浏览器不可用时提供可点击链接;用“查看记录”“继续编辑”等文案,不展示工作区数字 ID 或原始 JSON。
43
+ 链接约 5 分钟内一次性有效,不能作为长期书签;安全验证及原有业务权限不变。
44
+
33
45
  ## App Discovery
34
46
 
35
47
  If `app_key` is unknown, use `app_list` first. Pass `query` to filter visible apps by keyword.
@@ -237,6 +249,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
237
249
  code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
238
250
  task_context_tools = wrap_trimmed_methods(TaskContextTools(sessions, backend), USER_SERVER_METHOD_MAP)
239
251
  directory_tools = wrap_trimmed_methods(DirectoryTools(sessions, backend), USER_SERVER_METHOD_MAP)
252
+ login_link = LoginLinkTools(sessions, backend)
240
253
 
241
254
  @server.tool()
242
255
  def auth_use_credential(
@@ -258,6 +271,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
258
271
  def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
259
272
  return auth.auth_whoami(profile=profile)
260
273
 
274
+ @server.tool(description=LOGIN_LINK_DESCRIPTION)
275
+ def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
276
+ """获取一次性免登录链接;redirect_path 可选,仅接受站内业务路径,省略进入首页。"""
277
+ return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
278
+
261
279
  @server.tool()
262
280
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
263
281
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)