@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 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
@@ -87,7 +87,7 @@ OPENCODE_ENDPOINT_ALIASES = {
87
87
  }
88
88
 
89
89
  APP_NAME = "Ciel Runtime"
90
- VERSION = "0.2.24"
90
+ VERSION = "0.2.25"
91
91
  CREDITS = "Credits: One Ciel LLC"
92
92
  PRELAUNCH_CANCEL = 10
93
93
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -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(f"Z.AI OAuth HTTP {exc.code} at {url}") from exc
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 = secrets.token_hex(32)
254
- initialized = self.client.initialize(poll_token)
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
- zai = result.get("zai")
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:
@@ -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
- 발급한다. `--no-browser`는 인증 URL만 출력한다. Ciel은 OAuth access token이나
108
- ZCode JWT를 디스크에 저장하지 않는다. `logout`은 OAuth로 만든 로컬 API key만
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 동일한 init/poll OAuth 흐름: `ciel-runtimectl zai-oauth login`
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 local implementation cannot claim a completed live login while the upstream initialization endpoint returns 404."
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": "929 passed",
62
+ "router": "932 passed",
55
63
  "channel": "381 passed, 80 skipped",
56
64
  "runtime": "252 passed, 12 skipped",
57
- "aggregate": "2727 passed, 136 skipped",
58
- "package_dry_run": "oneciel-ai-ciel-runtime-0.2.24.tgz; 415 files; 947347 bytes packed; 4335786 bytes unpacked",
59
- "local_deployment": "installed into C:\\Users\\djlov\\.local\\share\\ciel-runtime; installed wrapper reported 0.2.24 and zai-oauth status exited 0",
60
- "live_oauth": "blocked by confirmed upstream HTTP 404; no local credential or config was changed"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",