@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43

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,264 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from contextlib import contextmanager
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Iterator, Literal
11
+ from uuid import uuid4
12
+
13
+ from .config import get_mcp_home, normalize_base_url
14
+ from .errors import QingflowApiError
15
+
16
+
17
+ OAUTH_PROFILE_SCHEMA_VERSION = 1
18
+ OAuthStateValue = Literal["oauth", "none"]
19
+
20
+
21
+ def _utcnow() -> str:
22
+ return datetime.now(timezone.utc).isoformat()
23
+
24
+
25
+ def profile_storage_key(profile: str) -> str:
26
+ return hashlib.sha256(profile.encode("utf-8")).hexdigest()
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class OAuthProfileState:
31
+ active_auth_type: OAuthStateValue
32
+ profile: str
33
+ base_url: str | None = None
34
+ qf_version: str | None = None
35
+ qf_version_source: str | None = None
36
+ uid: int | None = None
37
+ email: str | None = None
38
+ nick_name: str | None = None
39
+ selected_ws_id: int | None = None
40
+ selected_ws_name: str | None = None
41
+ persisted: bool = True
42
+ secret_storage: str | None = None
43
+ issuer: str | None = None
44
+ client_id: str | None = None
45
+ resource: str | None = None
46
+ source_code: str | None = None
47
+ access_expires_at: str | None = None
48
+ created_at: str = ""
49
+ updated_at: str = ""
50
+
51
+ @classmethod
52
+ def from_dict(cls, profile: str, value: object) -> "OAuthProfileState":
53
+ if not isinstance(value, dict):
54
+ raise _config_error(profile, "profile entry must be an object")
55
+ active_auth_type = value.get("active_auth_type")
56
+ if active_auth_type not in {"oauth", "none"}:
57
+ raise _config_error(profile, "active_auth_type must be 'oauth' or 'none'")
58
+ stored_profile = value.get("profile")
59
+ if stored_profile != profile:
60
+ raise _config_error(profile, "stored profile name does not match the requested profile")
61
+ secret_storage = _normalized_text(value.get("secret_storage"))
62
+ if active_auth_type == "oauth":
63
+ required_text = ("base_url", "issuer", "client_id", "resource", "source_code", "access_expires_at")
64
+ missing = [name for name in required_text if not _normalized_text(value.get(name))]
65
+ if secret_storage not in {"keyring", "file"}:
66
+ missing.append("secret_storage")
67
+ uid = _positive_int(value.get("uid"))
68
+ if uid is None:
69
+ missing.append("uid")
70
+ if missing:
71
+ raise _config_error(profile, "missing OAuth fields: " + ", ".join(missing))
72
+ created_at = _normalized_text(value.get("created_at")) or _utcnow()
73
+ updated_at = _normalized_text(value.get("updated_at")) or created_at
74
+ return cls(
75
+ active_auth_type=active_auth_type,
76
+ profile=profile,
77
+ base_url=_normalized_text(value.get("base_url")),
78
+ qf_version=_normalized_text(value.get("qf_version")),
79
+ qf_version_source=_normalized_text(value.get("qf_version_source")),
80
+ uid=_positive_int(value.get("uid")),
81
+ email=_normalized_text(value.get("email")),
82
+ nick_name=_normalized_text(value.get("nick_name")),
83
+ selected_ws_id=_positive_int(value.get("selected_ws_id")),
84
+ selected_ws_name=_normalized_text(value.get("selected_ws_name")),
85
+ persisted=bool(value.get("persisted", True)),
86
+ secret_storage=secret_storage,
87
+ issuer=_normalized_text(value.get("issuer")),
88
+ client_id=_normalized_text(value.get("client_id")),
89
+ resource=_normalized_text(value.get("resource")),
90
+ source_code=_normalized_text(value.get("source_code")),
91
+ access_expires_at=_normalized_text(value.get("access_expires_at")),
92
+ created_at=created_at,
93
+ updated_at=updated_at,
94
+ )
95
+
96
+ def to_dict(self) -> dict[str, object]:
97
+ return asdict(self)
98
+
99
+
100
+ class OAuthProfileStore:
101
+ def __init__(self, base_dir: Path | None = None) -> None:
102
+ self.base_dir = Path(base_dir) if base_dir is not None else get_mcp_home()
103
+ self._profiles_dir = self.base_dir / "oauth-profiles"
104
+ self._locks_dir = self.base_dir / "locks"
105
+
106
+ def get(self, profile: str) -> OAuthProfileState | None:
107
+ path = self.profile_path(profile)
108
+ if not path.exists():
109
+ return None
110
+ try:
111
+ with path.open("r", encoding="utf-8") as handle:
112
+ payload = json.load(handle)
113
+ except (OSError, json.JSONDecodeError) as exc:
114
+ raise _config_error(profile, f"state file cannot be read: {exc}") from exc
115
+ if not isinstance(payload, dict) or payload.get("schema_version") != OAUTH_PROFILE_SCHEMA_VERSION:
116
+ raise _config_error(profile, "unsupported or missing schema_version")
117
+ profiles = payload.get("profiles")
118
+ if not isinstance(profiles, dict) or profile not in profiles:
119
+ raise _config_error(profile, "state file does not contain the requested profile")
120
+ return OAuthProfileState.from_dict(profile, profiles[profile])
121
+
122
+ def save(self, state: OAuthProfileState, *, locked: bool = False) -> None:
123
+ if locked:
124
+ self._save_locked(state)
125
+ return
126
+ with self.lock(state.profile):
127
+ self._save_locked(state)
128
+
129
+ def set_none(self, profile: str, *, locked: bool = False) -> OAuthProfileState:
130
+ now = _utcnow()
131
+ try:
132
+ previous = self.get(profile)
133
+ except QingflowApiError:
134
+ previous = None
135
+ state = OAuthProfileState(
136
+ active_auth_type="none",
137
+ profile=profile,
138
+ persisted=True,
139
+ created_at=previous.created_at if previous is not None else now,
140
+ updated_at=now,
141
+ )
142
+ self.save(state, locked=locked)
143
+ return state
144
+
145
+ def delete(self, profile: str, *, locked: bool = False) -> None:
146
+ if not locked:
147
+ with self.lock(profile):
148
+ self.delete(profile, locked=True)
149
+ return
150
+ try:
151
+ self.profile_path(profile).unlink()
152
+ except FileNotFoundError:
153
+ return
154
+ except OSError as exc:
155
+ raise _config_error(profile, f"state file cannot be removed: {exc}") from exc
156
+
157
+ @contextmanager
158
+ def lock(self, profile: str) -> Iterator[None]:
159
+ lock_path = self._locks_dir / f"{profile_storage_key(profile)}.oauth.lock"
160
+ with _FileLock(lock_path):
161
+ yield
162
+
163
+ def profile_path(self, profile: str) -> Path:
164
+ return self._profiles_dir / f"{profile_storage_key(profile)}.json"
165
+
166
+ def _save_locked(self, state: OAuthProfileState) -> None:
167
+ normalized_base_url = normalize_base_url(state.base_url) if state.base_url else None
168
+ if state.active_auth_type == "oauth" and not normalized_base_url:
169
+ raise _config_error(state.profile, "base_url is invalid")
170
+ state.base_url = normalized_base_url
171
+ state.updated_at = _utcnow()
172
+ if not state.created_at:
173
+ state.created_at = state.updated_at
174
+ payload = {
175
+ "schema_version": OAUTH_PROFILE_SCHEMA_VERSION,
176
+ "profiles": {state.profile: state.to_dict()},
177
+ }
178
+ _atomic_json_write(self.profile_path(state.profile), payload)
179
+
180
+
181
+ class _FileLock:
182
+ def __init__(self, path: Path) -> None:
183
+ self.path = path
184
+ self._handle = None
185
+
186
+ def __enter__(self) -> "_FileLock":
187
+ self.path.parent.mkdir(parents=True, exist_ok=True)
188
+ self._handle = self.path.open("a+b")
189
+ self._handle.seek(0, os.SEEK_END)
190
+ if self._handle.tell() == 0:
191
+ self._handle.write(b"0")
192
+ self._handle.flush()
193
+ self._handle.seek(0)
194
+ if os.name == "nt":
195
+ import msvcrt
196
+
197
+ msvcrt.locking(self._handle.fileno(), msvcrt.LK_LOCK, 1)
198
+ else:
199
+ import fcntl
200
+
201
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX)
202
+ return self
203
+
204
+ def __exit__(self, _exc_type, _exc, _traceback) -> None:
205
+ if self._handle is None:
206
+ return
207
+ try:
208
+ self._handle.seek(0)
209
+ if os.name == "nt":
210
+ import msvcrt
211
+
212
+ msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
213
+ else:
214
+ import fcntl
215
+
216
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
217
+ finally:
218
+ self._handle.close()
219
+ self._handle = None
220
+
221
+
222
+ def _atomic_json_write(path: Path, payload: object) -> None:
223
+ path.parent.mkdir(parents=True, exist_ok=True)
224
+ temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp"
225
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
226
+ try:
227
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
228
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
229
+ handle.flush()
230
+ os.fsync(handle.fileno())
231
+ os.replace(temporary, path)
232
+ try:
233
+ os.chmod(path, 0o600)
234
+ except OSError:
235
+ pass
236
+ finally:
237
+ try:
238
+ temporary.unlink()
239
+ except FileNotFoundError:
240
+ pass
241
+
242
+
243
+ def _normalized_text(value: object) -> str | None:
244
+ if value is None:
245
+ return None
246
+ normalized = str(value).strip()
247
+ return normalized or None
248
+
249
+
250
+ def _positive_int(value: object) -> int | None:
251
+ if value is None or isinstance(value, bool):
252
+ return None
253
+ try:
254
+ parsed = int(value)
255
+ except (TypeError, ValueError):
256
+ return None
257
+ return parsed if parsed > 0 else None
258
+
259
+
260
+ def _config_error(profile: str, reason: str) -> QingflowApiError:
261
+ return QingflowApiError.config_error(
262
+ f"OAuth state for profile '{profile}' is invalid: {reason}.",
263
+ details={"reason": "oauth_state_invalid", "profile": profile},
264
+ )
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import keyring
9
+ except ImportError:
10
+ keyring = None
11
+
12
+ from .errors import QingflowApiError
13
+ from .json_types import KeyringBackend
14
+ from .oauth_profile_store import OAuthProfileStore, _atomic_json_write, profile_storage_key
15
+ from .session_store import KEYRING_SERVICE_NAME
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class OAuthTokenBundle:
20
+ access_token: str
21
+ refresh_token: str
22
+ access_expires_at: str
23
+ generation: int
24
+
25
+ @classmethod
26
+ def from_json(cls, profile: str, value: object) -> "OAuthTokenBundle":
27
+ if not isinstance(value, dict):
28
+ raise _bundle_error(profile, "bundle must be an object")
29
+ access_token = _text(value.get("access_token"))
30
+ refresh_token = _text(value.get("refresh_token"))
31
+ access_expires_at = _text(value.get("access_expires_at"))
32
+ generation_value = value.get("generation")
33
+ if isinstance(generation_value, bool):
34
+ generation = -1
35
+ else:
36
+ try:
37
+ generation = int(generation_value)
38
+ except (TypeError, ValueError):
39
+ generation = -1
40
+ missing = []
41
+ if not access_token:
42
+ missing.append("access_token")
43
+ if not refresh_token:
44
+ missing.append("refresh_token")
45
+ if not access_expires_at:
46
+ missing.append("access_expires_at")
47
+ if generation < 0:
48
+ missing.append("generation")
49
+ if missing:
50
+ raise _bundle_error(profile, "missing or invalid fields: " + ", ".join(missing))
51
+ return cls(
52
+ access_token=access_token,
53
+ refresh_token=refresh_token,
54
+ access_expires_at=access_expires_at,
55
+ generation=generation,
56
+ )
57
+
58
+ def to_json(self) -> dict[str, object]:
59
+ return asdict(self)
60
+
61
+
62
+ class OAuthTokenStore:
63
+ def __init__(
64
+ self,
65
+ profile_store: OAuthProfileStore,
66
+ *,
67
+ keyring_backend: KeyringBackend | None = None,
68
+ ) -> None:
69
+ self.profile_store = profile_store
70
+ self._keyring = keyring_backend if keyring_backend is not None else keyring
71
+ self._secrets_dir = profile_store.base_dir / "oauth-secrets"
72
+
73
+ def load(self, profile: str) -> OAuthTokenBundle | None:
74
+ state = self.profile_store.get(profile)
75
+ if state is not None and state.active_auth_type == "none":
76
+ return None
77
+ if state is not None and state.secret_storage == "file":
78
+ return self._load_file(profile)
79
+ if state is not None and state.secret_storage == "keyring":
80
+ keyring_value = self._get_keyring(profile)
81
+ return self._decode(profile, keyring_value, storage="keyring") if keyring_value is not None else None
82
+ keyring_value = self._get_keyring(profile)
83
+ if keyring_value is not None:
84
+ return self._decode(profile, keyring_value, storage="keyring")
85
+ return self._load_file(profile)
86
+
87
+ def _load_file(self, profile: str) -> OAuthTokenBundle | None:
88
+ path = self.secret_path(profile)
89
+ if not path.exists():
90
+ return None
91
+ try:
92
+ with path.open("r", encoding="utf-8") as handle:
93
+ payload = json.load(handle)
94
+ except (OSError, json.JSONDecodeError) as exc:
95
+ raise _bundle_error(profile, f"file bundle cannot be read: {exc}") from exc
96
+ return OAuthTokenBundle.from_json(profile, payload)
97
+
98
+ def save(
99
+ self,
100
+ profile: str,
101
+ bundle: OAuthTokenBundle,
102
+ *,
103
+ storage: str | None = None,
104
+ locked: bool = False,
105
+ ) -> str:
106
+ if not locked:
107
+ with self.profile_store.lock(profile):
108
+ state = self.profile_store.get(profile)
109
+ selected_storage = storage
110
+ if selected_storage is None and state is not None and state.active_auth_type == "oauth":
111
+ selected_storage = state.secret_storage
112
+ saved_storage = self.save(profile, bundle, storage=selected_storage, locked=True)
113
+ if state is not None and state.active_auth_type == "oauth":
114
+ state.secret_storage = saved_storage
115
+ state.access_expires_at = bundle.access_expires_at
116
+ self.profile_store.save(state, locked=True)
117
+ return saved_storage
118
+ if storage not in {None, "keyring", "file"}:
119
+ raise _bundle_error(profile, f"unsupported secret storage: {storage}")
120
+ encoded = json.dumps(bundle.to_json(), ensure_ascii=False, separators=(",", ":"))
121
+ if storage == "keyring":
122
+ if not self._set_keyring(profile, encoded):
123
+ raise _bundle_error(profile, "keyring write failed")
124
+ self._delete_file(profile)
125
+ return "keyring"
126
+ if storage == "file":
127
+ self._save_file(profile, bundle)
128
+ self._delete_keyring(profile)
129
+ return "file"
130
+ if self._set_keyring(profile, encoded):
131
+ self._delete_file(profile)
132
+ return "keyring"
133
+ self._save_file(profile, bundle)
134
+ self._delete_keyring(profile)
135
+ return "file"
136
+
137
+ def delete(self, profile: str, *, locked: bool = False) -> None:
138
+ if not locked:
139
+ with self.profile_store.lock(profile):
140
+ self.delete(profile, locked=True)
141
+ return
142
+ self._delete_keyring(profile)
143
+ self._delete_file(profile)
144
+
145
+ def secret_path(self, profile: str) -> Path:
146
+ return self._secrets_dir / f"{profile_storage_key(profile)}.json"
147
+
148
+ def _decode(self, profile: str, encoded: str, *, storage: str) -> OAuthTokenBundle:
149
+ try:
150
+ value = json.loads(encoded)
151
+ except json.JSONDecodeError as exc:
152
+ raise _bundle_error(profile, f"{storage} bundle is not valid JSON") from exc
153
+ return OAuthTokenBundle.from_json(profile, value)
154
+
155
+ def _key(self, profile: str) -> str:
156
+ return f"{profile}:oauth-token-set"
157
+
158
+ def _get_keyring(self, profile: str) -> str | None:
159
+ if self._keyring is None:
160
+ return None
161
+ try:
162
+ value = self._keyring.get_password(KEYRING_SERVICE_NAME, self._key(profile))
163
+ except Exception:
164
+ return None
165
+ return value if value else None
166
+
167
+ def _set_keyring(self, profile: str, value: str) -> bool:
168
+ if self._keyring is None:
169
+ return False
170
+ try:
171
+ self._keyring.set_password(KEYRING_SERVICE_NAME, self._key(profile), value)
172
+ except Exception:
173
+ return False
174
+ return True
175
+
176
+ def _delete_keyring(self, profile: str) -> None:
177
+ if self._keyring is None:
178
+ return
179
+ try:
180
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, self._key(profile))
181
+ except Exception:
182
+ pass
183
+
184
+ def _save_file(self, profile: str, bundle: OAuthTokenBundle) -> None:
185
+ try:
186
+ _atomic_json_write(self.secret_path(profile), bundle.to_json())
187
+ except OSError as exc:
188
+ raise _bundle_error(profile, f"file bundle cannot be written: {exc}") from exc
189
+
190
+ def _delete_file(self, profile: str) -> None:
191
+ try:
192
+ self.secret_path(profile).unlink()
193
+ except FileNotFoundError:
194
+ return
195
+ except OSError as exc:
196
+ raise _bundle_error(profile, f"file bundle cannot be removed: {exc}") from exc
197
+
198
+
199
+ def _text(value: object) -> str | None:
200
+ if value is None:
201
+ return None
202
+ normalized = str(value).strip()
203
+ return normalized or None
204
+
205
+
206
+ def _bundle_error(profile: str, reason: str) -> QingflowApiError:
207
+ return QingflowApiError.config_error(
208
+ f"OAuth token bundle for profile '{profile}' is invalid: {reason}.",
209
+ details={"reason": "oauth_token_bundle_invalid", "profile": profile},
210
+ )
@@ -22,11 +22,12 @@ from .tools.task_context_tools import TaskContextTools
22
22
  from .tools.workspace_tools import WorkspaceTools
23
23
 
24
24
 
25
- def build_user_server() -> FastMCP:
25
+ def build_user_server(*, stateless_http: bool = False) -> FastMCP:
26
26
  today = date.today()
27
27
  current_year = today.year
28
28
  server = FastMCP(
29
29
  "Qingflow App User MCP",
30
+ stateless_http=stateless_http,
30
31
  instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
31
32
 
32
33
  ## App Discovery