@oneciel-ai/ciel-runtime 0.2.24 → 0.2.25
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.
- package/CHANGELOG.md +7 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/zai_oauth.py +114 -19
- package/docs/CLI-Reference.md +5 -3
- package/docs/Providers.md +1 -1
- package/docs/journal/2026/08/23/zcode-zai-oauth-glm53-support.okf +15 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
This file records stable Ciel Runtime releases. Changes are grouped by user-visible
|
|
4
4
|
capability, followed by the complete commit ledger merged into each release.
|
|
5
5
|
|
|
6
|
+
## 0.2.25 — 2026-08-23
|
|
7
|
+
|
|
8
|
+
- Added the ZCode wrapper's authorization-code contract as a cross-platform manual
|
|
9
|
+
callback fallback when the ZCode CLI init endpoint returns HTTP 404.
|
|
10
|
+
- Validates the callback target and OAuth state before token exchange, retains
|
|
11
|
+
transient tokens in memory, and leaves configuration unchanged on cancellation.
|
|
12
|
+
|
|
6
13
|
## 0.2.24 — 2026-08-23
|
|
7
14
|
|
|
8
15
|
- Added cross-platform Z.AI OAuth login through ZCode's CLI init/poll contract and
|
|
@@ -8,6 +8,7 @@ access tokens and ZCode JWTs are deliberately kept in memory.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import hmac
|
|
11
12
|
import secrets
|
|
12
13
|
import time
|
|
13
14
|
import urllib.error
|
|
@@ -20,6 +21,9 @@ from typing import Any, Callable, Mapping
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
ZCODE_OAUTH_BASE_URL = "https://zcode.z.ai/api/v1"
|
|
24
|
+
ZAI_AUTHORIZE_ENDPOINT = "https://chat.z.ai/api/oauth/authorize"
|
|
25
|
+
ZAI_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg"
|
|
26
|
+
ZAI_OAUTH_REDIRECT_URI = "zcode://zai-auth/callback"
|
|
23
27
|
ZAI_BUSINESS_BASE_URL = "https://api.z.ai"
|
|
24
28
|
ZAI_OAUTH_PROVIDER = "zai"
|
|
25
29
|
ZAI_CODING_PLAN_KEY_NAME = "zcode-api-key"
|
|
@@ -29,6 +33,10 @@ ZAI_OAUTH_TIMEOUT_SECONDS = 300.0
|
|
|
29
33
|
class ZaiOAuthError(RuntimeError):
|
|
30
34
|
"""A bounded, secret-free OAuth diagnostic."""
|
|
31
35
|
|
|
36
|
+
def __init__(self, message: str, *, http_status: int | None = None) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.http_status = http_status
|
|
39
|
+
|
|
32
40
|
|
|
33
41
|
@dataclass(frozen=True, slots=True)
|
|
34
42
|
class ZaiOAuthInit:
|
|
@@ -69,7 +77,9 @@ class ZaiOAuthHttp:
|
|
|
69
77
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
70
78
|
raw = response.read(1_048_577)
|
|
71
79
|
except urllib.error.HTTPError as exc:
|
|
72
|
-
raise ZaiOAuthError(
|
|
80
|
+
raise ZaiOAuthError(
|
|
81
|
+
f"Z.AI OAuth HTTP {exc.code} at {url}", http_status=exc.code
|
|
82
|
+
) from exc
|
|
73
83
|
except urllib.error.URLError as exc:
|
|
74
84
|
reason = type(exc.reason).__name__ if exc.reason is not None else "network_error"
|
|
75
85
|
raise ZaiOAuthError(f"Z.AI OAuth network error ({reason}) at {url}") from exc
|
|
@@ -118,6 +128,50 @@ class ZaiOAuthClient:
|
|
|
118
128
|
raise ZaiOAuthError("Z.AI OAuth poll returned an invalid status.")
|
|
119
129
|
return data
|
|
120
130
|
|
|
131
|
+
@staticmethod
|
|
132
|
+
def authorize_url(state: str) -> str:
|
|
133
|
+
query = urllib.parse.urlencode(
|
|
134
|
+
{
|
|
135
|
+
"client_id": ZAI_OAUTH_CLIENT_ID,
|
|
136
|
+
"redirect_uri": ZAI_OAUTH_REDIRECT_URI,
|
|
137
|
+
"response_type": "code",
|
|
138
|
+
"state": state,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return f"{ZAI_AUTHORIZE_ENDPOINT}?{query}"
|
|
142
|
+
|
|
143
|
+
def exchange_callback(self, callback_url: str, expected_state: str) -> Mapping[str, Any]:
|
|
144
|
+
try:
|
|
145
|
+
parsed = urllib.parse.urlparse(callback_url.strip())
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise ZaiOAuthError("Z.AI returned an invalid OAuth callback URL.") from exc
|
|
148
|
+
path = f"/{parsed.path.strip('/')}"
|
|
149
|
+
if parsed.scheme != "zcode" or parsed.netloc != "zai-auth" or path != "/callback":
|
|
150
|
+
raise ZaiOAuthError("Z.AI returned an unexpected OAuth callback target.")
|
|
151
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
152
|
+
state = str((query.get("state") or [""])[0])
|
|
153
|
+
if not state or not hmac.compare_digest(state, expected_state):
|
|
154
|
+
raise ZaiOAuthError("Z.AI OAuth state did not match. Please retry login.")
|
|
155
|
+
error = str((query.get("error_description") or query.get("error") or [""])[0])
|
|
156
|
+
if error:
|
|
157
|
+
safe_error = " ".join(error.split())[:300]
|
|
158
|
+
raise ZaiOAuthError(f"Z.AI authorization failed: {safe_error}")
|
|
159
|
+
code = str((query.get("code") or query.get("authCode") or [""])[0])
|
|
160
|
+
if not code:
|
|
161
|
+
raise ZaiOAuthError("Z.AI OAuth callback did not include an authorization code.")
|
|
162
|
+
response = self.http.request(
|
|
163
|
+
"POST",
|
|
164
|
+
f"{self.oauth_base_url.rstrip('/')}/oauth/token",
|
|
165
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
166
|
+
body={
|
|
167
|
+
"provider": ZAI_OAUTH_PROVIDER,
|
|
168
|
+
"code": code,
|
|
169
|
+
"redirect_uri": ZAI_OAUTH_REDIRECT_URI,
|
|
170
|
+
"state": state,
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
return self._envelope_data(response, "token exchange")
|
|
174
|
+
|
|
121
175
|
def resolve_coding_plan_api_key(self, oauth_access_token: str) -> str:
|
|
122
176
|
login = self._business_data(
|
|
123
177
|
"POST",
|
|
@@ -247,11 +301,22 @@ class ZaiOAuthService:
|
|
|
247
301
|
now: Callable[[], float] = time.time
|
|
248
302
|
sleep: Callable[[float], None] = time.sleep
|
|
249
303
|
open_url: Callable[[str], bool] = webbrowser.open
|
|
304
|
+
read_callback: Callable[[str], str] = input
|
|
305
|
+
random_token: Callable[[], str] = lambda: secrets.token_hex(32)
|
|
250
306
|
timeout_seconds: float = ZAI_OAUTH_TIMEOUT_SECONDS
|
|
251
307
|
|
|
252
308
|
def login(self, *, no_browser: bool = False, on_authorize_url: Callable[[str], None]) -> ZaiOAuthResult:
|
|
253
|
-
poll_token =
|
|
254
|
-
|
|
309
|
+
poll_token = self.random_token()
|
|
310
|
+
try:
|
|
311
|
+
initialized = self.client.initialize(poll_token)
|
|
312
|
+
except ZaiOAuthError as exc:
|
|
313
|
+
if exc.http_status != 404:
|
|
314
|
+
raise
|
|
315
|
+
return self._authorization_code_login(
|
|
316
|
+
state=poll_token,
|
|
317
|
+
no_browser=no_browser,
|
|
318
|
+
on_authorize_url=on_authorize_url,
|
|
319
|
+
)
|
|
255
320
|
on_authorize_url(initialized.authorize_url)
|
|
256
321
|
if not no_browser:
|
|
257
322
|
self.open_url(initialized.authorize_url)
|
|
@@ -262,25 +327,55 @@ class ZaiOAuthService:
|
|
|
262
327
|
if status == "failed":
|
|
263
328
|
raise ZaiOAuthError("Z.AI OAuth authorization was denied or failed.")
|
|
264
329
|
if status == "ready":
|
|
265
|
-
|
|
266
|
-
access_token = (
|
|
267
|
-
str(zai.get("access_token") or "").strip()
|
|
268
|
-
if isinstance(zai, Mapping)
|
|
269
|
-
else ""
|
|
270
|
-
)
|
|
271
|
-
user = result.get("user")
|
|
272
|
-
user_id = (
|
|
273
|
-
str(user.get("user_id") or "").strip()
|
|
274
|
-
if isinstance(user, Mapping)
|
|
275
|
-
else ""
|
|
276
|
-
)
|
|
277
|
-
if not access_token or not user_id:
|
|
278
|
-
raise ZaiOAuthError("Z.AI OAuth ready response is missing credentials or user identity.")
|
|
279
|
-
api_key = self.client.resolve_coding_plan_api_key(access_token)
|
|
280
|
-
return ZaiOAuthResult(api_key=api_key, user_id=user_id)
|
|
330
|
+
return self._resolve_result(result)
|
|
281
331
|
self.sleep(min(initialized.poll_interval_seconds, max(0.0, deadline - self.now())))
|
|
282
332
|
raise ZaiOAuthError("Z.AI OAuth authorization timed out.")
|
|
283
333
|
|
|
334
|
+
def _authorization_code_login(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
state: str,
|
|
338
|
+
no_browser: bool,
|
|
339
|
+
on_authorize_url: Callable[[str], None],
|
|
340
|
+
) -> ZaiOAuthResult:
|
|
341
|
+
authorize_url = self.client.authorize_url(state)
|
|
342
|
+
on_authorize_url(authorize_url)
|
|
343
|
+
if not no_browser:
|
|
344
|
+
self.open_url(authorize_url)
|
|
345
|
+
try:
|
|
346
|
+
callback_url = self.read_callback(
|
|
347
|
+
"Paste the complete zcode://zai-auth/callback URL here: "
|
|
348
|
+
).strip()
|
|
349
|
+
except (EOFError, KeyboardInterrupt) as exc:
|
|
350
|
+
raise ZaiOAuthError(
|
|
351
|
+
"Z.AI OAuth callback URL was not provided; login was not changed."
|
|
352
|
+
) from exc
|
|
353
|
+
if not callback_url:
|
|
354
|
+
raise ZaiOAuthError(
|
|
355
|
+
"Z.AI OAuth callback URL was not provided; login was not changed."
|
|
356
|
+
)
|
|
357
|
+
return self._resolve_result(self.client.exchange_callback(callback_url, state))
|
|
358
|
+
|
|
359
|
+
def _resolve_result(self, result: Mapping[str, Any]) -> ZaiOAuthResult:
|
|
360
|
+
zai = result.get("zai")
|
|
361
|
+
access_token = (
|
|
362
|
+
str(zai.get("access_token") or "").strip()
|
|
363
|
+
if isinstance(zai, Mapping)
|
|
364
|
+
else ""
|
|
365
|
+
)
|
|
366
|
+
user = result.get("user")
|
|
367
|
+
user_id = (
|
|
368
|
+
str(user.get("user_id") or "").strip()
|
|
369
|
+
if isinstance(user, Mapping)
|
|
370
|
+
else ""
|
|
371
|
+
)
|
|
372
|
+
if not access_token or not user_id:
|
|
373
|
+
raise ZaiOAuthError(
|
|
374
|
+
"Z.AI OAuth ready response is missing credentials or user identity."
|
|
375
|
+
)
|
|
376
|
+
api_key = self.client.resolve_coding_plan_api_key(access_token)
|
|
377
|
+
return ZaiOAuthResult(api_key=api_key, user_id=user_id)
|
|
378
|
+
|
|
284
379
|
|
|
285
380
|
@dataclass(frozen=True, slots=True)
|
|
286
381
|
class ZaiOAuthRuntimePorts:
|
package/docs/CLI-Reference.md
CHANGED
|
@@ -104,9 +104,11 @@ ciel-runtimectl zai-oauth logout
|
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
ZCode CLI의 Z.AI init/poll OAuth 흐름으로 로그인하고 Coding Plan API key를
|
|
107
|
-
발급한다.
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
발급한다. init endpoint가 404이면 공개 ZCode 래퍼와 동일한 authorization-code
|
|
108
|
+
계약으로 전환하며, 터미널에 완전한 `zcode://zai-auth/callback` URL을 붙여 넣어
|
|
109
|
+
완료한다. `--no-browser`는 브라우저를 자동으로 열지 않는다. Ciel은 OAuth access
|
|
110
|
+
token이나 ZCode JWT를 디스크에 저장하지 않는다. `logout`은 OAuth로 만든 로컬
|
|
111
|
+
API key만 지우며 원격 승인을 철회하지 않는다.
|
|
110
112
|
|
|
111
113
|
#### `api-key`
|
|
112
114
|
```bash
|
package/docs/Providers.md
CHANGED
|
@@ -183,7 +183,7 @@ Anthropic thinking 객체나 문서로 확인되지 않은 GLM-5.2 effort 문자
|
|
|
183
183
|
|
|
184
184
|
- GLM 시리즈 모델 제공.
|
|
185
185
|
- 기본 모델: `glm-5.3[1m]`
|
|
186
|
-
- ZCode CLI
|
|
186
|
+
- ZCode CLI의 init/poll 및 authorization-code fallback OAuth 흐름: `ciel-runtimectl zai-oauth login`
|
|
187
187
|
- OAuth access token과 ZCode JWT는 메모리에만 유지하고, 최종 Coding Plan API key만 기존 Ciel credential 규칙으로 저장한다.
|
|
188
188
|
- GLM-5.3은 reasoning을 끌 수 없으며 `low`, `high`, `max` effort만 사용한다.
|
|
189
189
|
- Managed MCP 서버 포함: `web-search-prime`, `web-reader`, `zread`
|
|
@@ -32,7 +32,14 @@
|
|
|
32
32
|
"observed_result": "HTTP 404 with an empty response on 2026-08-23 from this workstation",
|
|
33
33
|
"control": "The same result was reproduced by invoking the bundled zcode.cjs login command directly.",
|
|
34
34
|
"config_unchanged": true,
|
|
35
|
-
"conclusion": "The
|
|
35
|
+
"conclusion": "The init/poll path is currently unavailable and must trigger the authorization-code fallback."
|
|
36
|
+
},
|
|
37
|
+
"authorization_code_probe": {
|
|
38
|
+
"authorize_endpoint": "https://chat.z.ai/api/oauth/authorize",
|
|
39
|
+
"token_endpoint": "https://zcode.z.ai/api/v1/oauth/token",
|
|
40
|
+
"observed_result": "authorize returned HTTP 307 to /auth; token endpoint returned a structured code 2007 response for an intentionally invalid code",
|
|
41
|
+
"source_contract": "ZCode wrapper revision ce2dcfb and bundled zcode.cjs callback-stdin path",
|
|
42
|
+
"conclusion": "The authorization-code endpoints are live and can be used as the verified fallback contract; account authorization was not performed by the agent."
|
|
36
43
|
}
|
|
37
44
|
},
|
|
38
45
|
"implementation": {
|
|
@@ -40,7 +47,8 @@
|
|
|
40
47
|
"Added bounded init/poll client and browser/no-browser service.",
|
|
41
48
|
"Added Coding Plan organization/project/API-key resolution.",
|
|
42
49
|
"Persists only the final API key after the complete flow succeeds.",
|
|
43
|
-
"A failed flow does not mutate configuration; logout does not remove manually configured keys."
|
|
50
|
+
"A failed flow does not mutate configuration; logout does not remove manually configured keys.",
|
|
51
|
+
"Falls back on init HTTP 404 to a state-validated authorization-code callback pasted by the user."
|
|
44
52
|
],
|
|
45
53
|
"glm_5_3": [
|
|
46
54
|
"Added catalog/default/context metadata.",
|
|
@@ -51,12 +59,12 @@
|
|
|
51
59
|
"verification": {
|
|
52
60
|
"targeted": "33 passed",
|
|
53
61
|
"unit": "1165 passed, 44 skipped",
|
|
54
|
-
"router": "
|
|
62
|
+
"router": "932 passed",
|
|
55
63
|
"channel": "381 passed, 80 skipped",
|
|
56
64
|
"runtime": "252 passed, 12 skipped",
|
|
57
|
-
"aggregate": "
|
|
58
|
-
"package_dry_run": "oneciel-ai-ciel-runtime-0.2.
|
|
59
|
-
"local_deployment": "installed into C:\\Users\\djlov\\.local\\share\\ciel-runtime; installed wrapper reported 0.2.
|
|
60
|
-
"live_oauth": "
|
|
65
|
+
"aggregate": "2730 passed, 136 skipped",
|
|
66
|
+
"package_dry_run": "oneciel-ai-ciel-runtime-0.2.25.tgz; 415 files; 948640 bytes packed; 4341060 bytes unpacked",
|
|
67
|
+
"local_deployment": "installed into C:\\Users\\djlov\\.local\\share\\ciel-runtime; installed wrapper reported 0.2.25 and zai-oauth status exited 0",
|
|
68
|
+
"live_oauth": "init returned HTTP 404, the verified authorization URL was emitted, missing callback input cancelled with exit 1, and the config SHA-256 remained unchanged; successful account authorization was not performed"
|
|
61
69
|
}
|
|
62
70
|
}
|
package/package.json
CHANGED