@oneciel-ai/ciel-runtime 0.2.27 → 0.2.28
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 +11 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/zai_oauth.py +46 -23
- package/ciel_runtime_support/zai_oauth_callback.py +157 -0
- package/docs/CLI-Reference.md +7 -4
- package/docs/Providers.md +1 -1
- package/docs/journal/2026/08/24/kevin-codex-config-mia-zai-oauth-recovery.okf +17 -4
- package/docs/journal/2026/08/24/mia-zai-oauth-state-mismatch.okf +74 -0
- package/docs/journal/2026/08/24/zai-localhost-oauth-callback.okf +70 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.28 — 2026-08-24
|
|
7
|
+
|
|
8
|
+
- Replaced manual `zcode://` callback pasting in the Z.AI authorization-code
|
|
9
|
+
fallback with a one-shot, state-bound listener at
|
|
10
|
+
`http://localhost:9899/callback`.
|
|
11
|
+
- Validate the exact callback host, port, path, and state before token exchange;
|
|
12
|
+
reject occupied ports, unrelated paths, oversized request targets, and stale
|
|
13
|
+
callbacks without changing the stored credential.
|
|
14
|
+
- Use the same localhost redirect URI for authorization and token exchange, and
|
|
15
|
+
return a secret-free browser completion page.
|
|
16
|
+
|
|
6
17
|
## 0.2.27 — 2026-08-24
|
|
7
18
|
|
|
8
19
|
- Repair Codex startup only when an invalid TOML file contains equivalent MCP
|
|
@@ -19,11 +19,16 @@ from dataclasses import dataclass
|
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
20
|
from typing import Any, Callable, Mapping
|
|
21
21
|
|
|
22
|
+
from .zai_oauth_callback import (
|
|
23
|
+
ZAI_OAUTH_CALLBACK_REDIRECT_URI,
|
|
24
|
+
ZaiOAuthLocalCallbackReceiver,
|
|
25
|
+
)
|
|
26
|
+
|
|
22
27
|
|
|
23
28
|
ZCODE_OAUTH_BASE_URL = "https://zcode.z.ai/api/v1"
|
|
24
29
|
ZAI_AUTHORIZE_ENDPOINT = "https://chat.z.ai/api/oauth/authorize"
|
|
25
30
|
ZAI_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg"
|
|
26
|
-
ZAI_OAUTH_REDIRECT_URI =
|
|
31
|
+
ZAI_OAUTH_REDIRECT_URI = ZAI_OAUTH_CALLBACK_REDIRECT_URI
|
|
27
32
|
ZAI_BUSINESS_BASE_URL = "https://api.z.ai"
|
|
28
33
|
ZAI_OAUTH_PROVIDER = "zai"
|
|
29
34
|
ZAI_CODING_PLAN_KEY_NAME = "zcode-api-key"
|
|
@@ -129,24 +134,42 @@ class ZaiOAuthClient:
|
|
|
129
134
|
return data
|
|
130
135
|
|
|
131
136
|
@staticmethod
|
|
132
|
-
def authorize_url(state: str) -> str:
|
|
137
|
+
def authorize_url(state: str, redirect_uri: str = ZAI_OAUTH_REDIRECT_URI) -> str:
|
|
133
138
|
query = urllib.parse.urlencode(
|
|
134
139
|
{
|
|
135
140
|
"client_id": ZAI_OAUTH_CLIENT_ID,
|
|
136
|
-
"redirect_uri":
|
|
141
|
+
"redirect_uri": redirect_uri,
|
|
137
142
|
"response_type": "code",
|
|
138
143
|
"state": state,
|
|
139
144
|
}
|
|
140
145
|
)
|
|
141
146
|
return f"{ZAI_AUTHORIZE_ENDPOINT}?{query}"
|
|
142
147
|
|
|
143
|
-
def exchange_callback(
|
|
148
|
+
def exchange_callback(
|
|
149
|
+
self,
|
|
150
|
+
callback_url: str,
|
|
151
|
+
expected_state: str,
|
|
152
|
+
redirect_uri: str = ZAI_OAUTH_REDIRECT_URI,
|
|
153
|
+
) -> Mapping[str, Any]:
|
|
144
154
|
try:
|
|
145
155
|
parsed = urllib.parse.urlparse(callback_url.strip())
|
|
146
156
|
except ValueError as exc:
|
|
147
157
|
raise ZaiOAuthError("Z.AI returned an invalid OAuth callback URL.") from exc
|
|
148
158
|
path = f"/{parsed.path.strip('/')}"
|
|
149
|
-
|
|
159
|
+
try:
|
|
160
|
+
expected = urllib.parse.urlparse(redirect_uri)
|
|
161
|
+
callback_target_matches = (
|
|
162
|
+
parsed.scheme.lower() == expected.scheme.lower()
|
|
163
|
+
and (parsed.hostname or "").lower() == (expected.hostname or "").lower()
|
|
164
|
+
and parsed.port == expected.port
|
|
165
|
+
and path == f"/{expected.path.strip('/')}"
|
|
166
|
+
and not parsed.username
|
|
167
|
+
and not parsed.password
|
|
168
|
+
and not parsed.fragment
|
|
169
|
+
)
|
|
170
|
+
except ValueError:
|
|
171
|
+
callback_target_matches = False
|
|
172
|
+
if not callback_target_matches:
|
|
150
173
|
if (
|
|
151
174
|
parsed.scheme == "https"
|
|
152
175
|
and parsed.hostname == "chat.z.ai"
|
|
@@ -154,7 +177,7 @@ class ZaiOAuthClient:
|
|
|
154
177
|
):
|
|
155
178
|
raise ZaiOAuthError(
|
|
156
179
|
"The pasted URL is the authorization page, not the completed callback. "
|
|
157
|
-
"Finish authorization and
|
|
180
|
+
"Finish authorization in the browser and wait for the localhost callback."
|
|
158
181
|
)
|
|
159
182
|
raise ZaiOAuthError("Z.AI returned an unexpected OAuth callback target.")
|
|
160
183
|
query = urllib.parse.parse_qs(parsed.query)
|
|
@@ -175,7 +198,7 @@ class ZaiOAuthClient:
|
|
|
175
198
|
body={
|
|
176
199
|
"provider": ZAI_OAUTH_PROVIDER,
|
|
177
200
|
"code": code,
|
|
178
|
-
"redirect_uri":
|
|
201
|
+
"redirect_uri": redirect_uri,
|
|
179
202
|
"state": state,
|
|
180
203
|
},
|
|
181
204
|
)
|
|
@@ -310,7 +333,7 @@ class ZaiOAuthService:
|
|
|
310
333
|
now: Callable[[], float] = time.time
|
|
311
334
|
sleep: Callable[[float], None] = time.sleep
|
|
312
335
|
open_url: Callable[[str], bool] = webbrowser.open
|
|
313
|
-
|
|
336
|
+
callback_receiver_factory: Callable[[str, float], Any] = ZaiOAuthLocalCallbackReceiver
|
|
314
337
|
random_token: Callable[[], str] = lambda: secrets.token_hex(32)
|
|
315
338
|
timeout_seconds: float = ZAI_OAUTH_TIMEOUT_SECONDS
|
|
316
339
|
|
|
@@ -347,23 +370,23 @@ class ZaiOAuthService:
|
|
|
347
370
|
no_browser: bool,
|
|
348
371
|
on_authorize_url: Callable[[str], None],
|
|
349
372
|
) -> ZaiOAuthResult:
|
|
350
|
-
authorize_url = self.client.authorize_url(state)
|
|
351
|
-
on_authorize_url(authorize_url)
|
|
352
|
-
if not no_browser:
|
|
353
|
-
self.open_url(authorize_url)
|
|
354
373
|
try:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
raise ZaiOAuthError(
|
|
364
|
-
|
|
374
|
+
with self.callback_receiver_factory(state, self.timeout_seconds) as receiver:
|
|
375
|
+
redirect_uri = receiver.redirect_uri
|
|
376
|
+
authorize_url = self.client.authorize_url(state, redirect_uri)
|
|
377
|
+
on_authorize_url(authorize_url)
|
|
378
|
+
if not no_browser:
|
|
379
|
+
self.open_url(authorize_url)
|
|
380
|
+
callback_url = receiver.wait()
|
|
381
|
+
except RuntimeError as exc:
|
|
382
|
+
raise ZaiOAuthError(str(exc)) from exc
|
|
383
|
+
return self._resolve_result(
|
|
384
|
+
self.client.exchange_callback(
|
|
385
|
+
callback_url,
|
|
386
|
+
state,
|
|
387
|
+
redirect_uri=redirect_uri,
|
|
365
388
|
)
|
|
366
|
-
|
|
389
|
+
)
|
|
367
390
|
|
|
368
391
|
def _resolve_result(self, result: Mapping[str, Any]) -> ZaiOAuthResult:
|
|
369
392
|
zai = result.get("zai")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Loopback callback receiver for the Z.AI authorization-code flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ZAI_OAUTH_CALLBACK_HOST = "127.0.0.1"
|
|
14
|
+
ZAI_OAUTH_CALLBACK_PUBLIC_HOST = "localhost"
|
|
15
|
+
ZAI_OAUTH_CALLBACK_PORT = 9899
|
|
16
|
+
ZAI_OAUTH_CALLBACK_PATH = "/callback"
|
|
17
|
+
ZAI_OAUTH_CALLBACK_REDIRECT_URI = "http://localhost:9899/callback"
|
|
18
|
+
_MAX_REQUEST_TARGET_BYTES = 8_192
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ExclusiveThreadingHTTPServer(ThreadingHTTPServer):
|
|
22
|
+
allow_reuse_address = False
|
|
23
|
+
daemon_threads = True
|
|
24
|
+
|
|
25
|
+
def server_bind(self) -> None:
|
|
26
|
+
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
|
27
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
28
|
+
super().server_bind()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ZaiOAuthLocalCallbackReceiver:
|
|
33
|
+
"""Receive one state-bound OAuth callback on a loopback-only HTTP server."""
|
|
34
|
+
|
|
35
|
+
expected_state: str
|
|
36
|
+
timeout_seconds: float
|
|
37
|
+
host: str = ZAI_OAUTH_CALLBACK_HOST
|
|
38
|
+
port: int = ZAI_OAUTH_CALLBACK_PORT
|
|
39
|
+
public_host: str = ZAI_OAUTH_CALLBACK_PUBLIC_HOST
|
|
40
|
+
path: str = ZAI_OAUTH_CALLBACK_PATH
|
|
41
|
+
_server: _ExclusiveThreadingHTTPServer | None = field(default=None, init=False)
|
|
42
|
+
_thread: threading.Thread | None = field(default=None, init=False)
|
|
43
|
+
_callback_url: str = field(default="", init=False)
|
|
44
|
+
_callback_ready: threading.Event = field(default_factory=threading.Event, init=False)
|
|
45
|
+
_callback_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def redirect_uri(self) -> str:
|
|
49
|
+
port = self._server.server_port if self._server is not None else self.port
|
|
50
|
+
return f"http://{self.public_host}:{port}{self.path}"
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> ZaiOAuthLocalCallbackReceiver:
|
|
53
|
+
receiver = self
|
|
54
|
+
|
|
55
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
56
|
+
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
|
|
57
|
+
receiver._handle_get(self)
|
|
58
|
+
|
|
59
|
+
def log_message(self, _format: str, *_args: object) -> None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
self._server = _ExclusiveThreadingHTTPServer((self.host, self.port), CallbackHandler)
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"Z.AI OAuth callback listener could not bind to {self.host}:{self.port}."
|
|
67
|
+
) from exc
|
|
68
|
+
self._thread = threading.Thread(
|
|
69
|
+
target=self._server.serve_forever,
|
|
70
|
+
kwargs={"poll_interval": 0.05},
|
|
71
|
+
name="ciel-zai-oauth-callback",
|
|
72
|
+
daemon=True,
|
|
73
|
+
)
|
|
74
|
+
self._thread.start()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None:
|
|
78
|
+
server, thread = self._server, self._thread
|
|
79
|
+
if server is not None:
|
|
80
|
+
server.shutdown()
|
|
81
|
+
server.server_close()
|
|
82
|
+
if thread is not None:
|
|
83
|
+
thread.join(timeout=2.0)
|
|
84
|
+
self._server = None
|
|
85
|
+
self._thread = None
|
|
86
|
+
|
|
87
|
+
def wait(self) -> str:
|
|
88
|
+
if self._server is None:
|
|
89
|
+
raise RuntimeError("Z.AI OAuth callback listener was not started.")
|
|
90
|
+
if not self._callback_ready.wait(max(0.0, self.timeout_seconds)):
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Z.AI OAuth localhost callback timed out after {int(self.timeout_seconds)} seconds."
|
|
93
|
+
)
|
|
94
|
+
with self._callback_lock:
|
|
95
|
+
callback_url = self._callback_url
|
|
96
|
+
if not callback_url:
|
|
97
|
+
raise RuntimeError("Z.AI OAuth localhost callback was empty.")
|
|
98
|
+
return callback_url
|
|
99
|
+
|
|
100
|
+
def _handle_get(self, handler: BaseHTTPRequestHandler) -> None:
|
|
101
|
+
if len(handler.path.encode("utf-8", errors="ignore")) > _MAX_REQUEST_TARGET_BYTES:
|
|
102
|
+
self._respond(handler, 414, "OAuth callback request was too large.")
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
parsed = urllib.parse.urlsplit(handler.path)
|
|
106
|
+
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
|
107
|
+
except ValueError:
|
|
108
|
+
self._respond(handler, 400, "Invalid OAuth callback request.")
|
|
109
|
+
return
|
|
110
|
+
if parsed.path != self.path:
|
|
111
|
+
self._respond(handler, 404, "OAuth callback path was not found.")
|
|
112
|
+
return
|
|
113
|
+
state = str((query.get("state") or [""])[0])
|
|
114
|
+
if not state or not hmac.compare_digest(state, self.expected_state):
|
|
115
|
+
self._respond(handler, 400, "OAuth callback state did not match.")
|
|
116
|
+
return
|
|
117
|
+
with self._callback_lock:
|
|
118
|
+
if self._callback_url:
|
|
119
|
+
self._respond(handler, 409, "OAuth callback was already received.")
|
|
120
|
+
return
|
|
121
|
+
self._callback_url = (
|
|
122
|
+
f"{self.redirect_uri}?{parsed.query}" if parsed.query else self.redirect_uri
|
|
123
|
+
)
|
|
124
|
+
self._respond(
|
|
125
|
+
handler,
|
|
126
|
+
200,
|
|
127
|
+
"Z.AI authorization received. You may close this tab and return to Ciel Runtime.",
|
|
128
|
+
)
|
|
129
|
+
self._callback_ready.set()
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _respond(handler: BaseHTTPRequestHandler, status: int, message: str) -> None:
|
|
133
|
+
body = (
|
|
134
|
+
"<!doctype html><meta charset=\"utf-8\"><title>Ciel Runtime OAuth</title>"
|
|
135
|
+
f"<p>{message}</p>"
|
|
136
|
+
).encode("utf-8")
|
|
137
|
+
handler.send_response(status)
|
|
138
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
139
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
140
|
+
handler.send_header("Cache-Control", "no-store")
|
|
141
|
+
handler.send_header("Connection", "close")
|
|
142
|
+
handler.end_headers()
|
|
143
|
+
handler.close_connection = True
|
|
144
|
+
try:
|
|
145
|
+
handler.wfile.write(body)
|
|
146
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = [
|
|
151
|
+
"ZAI_OAUTH_CALLBACK_HOST",
|
|
152
|
+
"ZAI_OAUTH_CALLBACK_PATH",
|
|
153
|
+
"ZAI_OAUTH_CALLBACK_PORT",
|
|
154
|
+
"ZAI_OAUTH_CALLBACK_PUBLIC_HOST",
|
|
155
|
+
"ZAI_OAUTH_CALLBACK_REDIRECT_URI",
|
|
156
|
+
"ZaiOAuthLocalCallbackReceiver",
|
|
157
|
+
]
|
package/docs/CLI-Reference.md
CHANGED
|
@@ -120,10 +120,13 @@ ciel-runtimectl zai-oauth logout
|
|
|
120
120
|
|
|
121
121
|
ZCode CLI의 Z.AI init/poll OAuth 흐름으로 로그인하고 Coding Plan API key를
|
|
122
122
|
발급한다. init endpoint가 404이면 공개 ZCode 래퍼와 동일한 authorization-code
|
|
123
|
-
계약으로 전환하며,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
계약으로 전환하며, `http://localhost:9899/callback`의 일회성 loopback listener가
|
|
124
|
+
브라우저 콜백을 자동으로 수신한다. `--no-browser`는 브라우저를 자동으로 열지
|
|
125
|
+
않지만 동일한 listener에서 기다린다. 브라우저와 Ciel Runtime이 서로 다른
|
|
126
|
+
머신이면 먼저 `ssh -L 9899:127.0.0.1:9899 user@runtime-host`처럼 callback port를
|
|
127
|
+
전달해야 한다. Ciel은 OAuth access token이나 ZCode JWT를 디스크에 저장하지
|
|
128
|
+
않는다. `logout`은 OAuth로 만든 로컬 API key만 지우며 원격 승인을 철회하지
|
|
129
|
+
않는다.
|
|
127
130
|
|
|
128
131
|
#### `api-key`
|
|
129
132
|
```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 및 authorization-code fallback OAuth 흐름: `ciel-runtimectl zai-oauth login`
|
|
186
|
+
- ZCode CLI의 init/poll 및 localhost:9899 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`
|
|
@@ -6,7 +6,7 @@ knowledge:
|
|
|
6
6
|
objective: >-
|
|
7
7
|
Restore Kevin's Codex startup and prevent Mia's Z.AI OAuth callback input
|
|
8
8
|
error from terminating the Ciel prelaunch interface.
|
|
9
|
-
status:
|
|
9
|
+
status: complete
|
|
10
10
|
|
|
11
11
|
evidence:
|
|
12
12
|
kevin:
|
|
@@ -23,6 +23,11 @@ knowledge:
|
|
|
23
23
|
toml_valid: true
|
|
24
24
|
codex_mcp_list_exit: 0
|
|
25
25
|
ciel_runtime_codex_version_exit: 0
|
|
26
|
+
nightly_installed: 0.2.27-nightly.20260824-061806.8e24b2e
|
|
27
|
+
post_install_observation: >-
|
|
28
|
+
The first Ciel-routed Codex version probe encountered a transient router
|
|
29
|
+
port claim failure on 8802. The listener was absent immediately after the
|
|
30
|
+
failure, and the identical probe then exited 0 with codex-cli 0.149.1.
|
|
26
31
|
backup: /home/kevin-codex/.codex/config.toml.ciel-repair-20260824T0555Z.bak
|
|
27
32
|
attribution: >-
|
|
28
33
|
The config and AI-Net managed artifacts were written in the same second,
|
|
@@ -33,6 +38,10 @@ knowledge:
|
|
|
33
38
|
source_behavior: >-
|
|
34
39
|
ZaiOAuthClient rejected the target with ZaiOAuthError and the prelaunch
|
|
35
40
|
API-key panel invoked the action without a RuntimeError boundary.
|
|
41
|
+
nightly_installed: 0.2.27-nightly.20260824-061806.8e24b2e
|
|
42
|
+
installed_guard_probe:
|
|
43
|
+
exit: 0
|
|
44
|
+
result: authorize page rejected with completed callback guidance
|
|
36
45
|
|
|
37
46
|
implementation:
|
|
38
47
|
codex_preflight:
|
|
@@ -64,6 +73,10 @@ knowledge:
|
|
|
64
73
|
- npm package dry run for version 0.2.27
|
|
65
74
|
- local install reports ciel-runtime 0.2.27
|
|
66
75
|
- local Ciel-routed Codex version exit 0
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
76
|
+
- npm latest reports 0.2.27
|
|
77
|
+
- npm nightly reports 0.2.27-nightly.20260824-061806.8e24b2e
|
|
78
|
+
- registry-installed nightly execution reports ciel-runtime 0.2.27
|
|
79
|
+
- main CI and npm publication workflows succeeded
|
|
80
|
+
- nightly CI and npm publication workflows succeeded
|
|
81
|
+
- Kevin nightly installation and Ciel-routed Codex version probe exited 0
|
|
82
|
+
- Mia nightly installation and installed OAuth guard probe exited 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
okf_version: "1.0"
|
|
2
|
+
knowledge:
|
|
3
|
+
task:
|
|
4
|
+
id: mia-zai-oauth-state-mismatch-20260824
|
|
5
|
+
date: "2026-08-24"
|
|
6
|
+
objective: >-
|
|
7
|
+
Diagnose Mia's Z.AI OAuth state mismatch without weakening the OAuth
|
|
8
|
+
request-to-callback binding.
|
|
9
|
+
status: diagnosis-complete
|
|
10
|
+
|
|
11
|
+
evidence:
|
|
12
|
+
supplied_values:
|
|
13
|
+
authorization_state_length: 64
|
|
14
|
+
callback_state_length: 64
|
|
15
|
+
states_equal: false
|
|
16
|
+
secrets_recorded: false
|
|
17
|
+
installed_runtime:
|
|
18
|
+
host: mia@100.95.132.58
|
|
19
|
+
version: 0.2.27-nightly.20260824-061806.8e24b2e
|
|
20
|
+
ciel_source:
|
|
21
|
+
comparison: hmac.compare_digest(callback_state, expected_state)
|
|
22
|
+
token_exchange_before_mismatch: false
|
|
23
|
+
zcode_source:
|
|
24
|
+
repository: https://github.com/kingsword09/zcode-cli.git
|
|
25
|
+
revision: ce2dcfbdeee3e5cca54095fbe1191fb2d03c10db
|
|
26
|
+
behavior: rejects callbacks whose state differs from the active login state
|
|
27
|
+
live_provider_probe:
|
|
28
|
+
initial_endpoint_status: 307
|
|
29
|
+
redirect_target: /auth
|
|
30
|
+
state_preserved: true
|
|
31
|
+
browser_authorization_page_state_preserved: true
|
|
32
|
+
mia_recovery_flow:
|
|
33
|
+
oauth_status: connected
|
|
34
|
+
stale_waiting_process: terminated
|
|
35
|
+
expected_callback: complete callback URL from the same authorization flow
|
|
36
|
+
localhost_callback_test:
|
|
37
|
+
redirect_uri: http://localhost:9899/callback
|
|
38
|
+
provider_accepted_redirect_uri: true
|
|
39
|
+
callback_received: true
|
|
40
|
+
authorization_code_present: true
|
|
41
|
+
state_match: true
|
|
42
|
+
authorization_code_recorded: false
|
|
43
|
+
post_login_provider_test:
|
|
44
|
+
ciel_route:
|
|
45
|
+
endpoint: https://api.z.ai/api/anthropic
|
|
46
|
+
model: glm-5.3
|
|
47
|
+
status: 429
|
|
48
|
+
provider_code: "1113"
|
|
49
|
+
provider_message: Insufficient balance or no resource package
|
|
50
|
+
official_zcode_direct:
|
|
51
|
+
client: zcode-app-cli 3.8.1-15
|
|
52
|
+
runtime: 0.16.3
|
|
53
|
+
endpoint: https://api.z.ai/api/anthropic
|
|
54
|
+
model: glm-5.3
|
|
55
|
+
same_oauth_resolved_key: true
|
|
56
|
+
status: 429
|
|
57
|
+
provider_code: "1113"
|
|
58
|
+
provider_message: Insufficient balance or no resource package
|
|
59
|
+
|
|
60
|
+
conclusion:
|
|
61
|
+
confirmed: >-
|
|
62
|
+
The callback supplied by the user was not bound to the active authorization
|
|
63
|
+
request. Accepting it would bypass the OAuth CSRF state check and is not a
|
|
64
|
+
valid fix.
|
|
65
|
+
code_change_required: false
|
|
66
|
+
provider_model: >-
|
|
67
|
+
Z.AI Direct API and Z.AI Coding Plan are distinct credential and
|
|
68
|
+
entitlement profiles. ZCode remains a client, not the upstream provider.
|
|
69
|
+
retry_cause_excluded:
|
|
70
|
+
- Ciel Runtime User-Agent difference
|
|
71
|
+
- Ciel Runtime request-shape difference
|
|
72
|
+
unresolved_external_state: >-
|
|
73
|
+
The OAuth-resolved Coding Plan key has no usable resource package on the
|
|
74
|
+
upstream account according to provider code 1113.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
okf_version: "1.0"
|
|
2
|
+
knowledge:
|
|
3
|
+
task:
|
|
4
|
+
id: zai-localhost-oauth-callback-20260824
|
|
5
|
+
date: "2026-08-24"
|
|
6
|
+
objective: >-
|
|
7
|
+
Replace manual Z.AI custom-scheme callback entry with an automatic,
|
|
8
|
+
state-bound localhost OAuth callback receiver.
|
|
9
|
+
status: verified
|
|
10
|
+
|
|
11
|
+
requirements:
|
|
12
|
+
redirect_uri: http://localhost:9899/callback
|
|
13
|
+
bind_address: 127.0.0.1
|
|
14
|
+
automatic_browser_callback: true
|
|
15
|
+
preserve_state_validation: true
|
|
16
|
+
token_exchange_redirect_must_match: true
|
|
17
|
+
|
|
18
|
+
implementation:
|
|
19
|
+
receiver: ciel_runtime_support/zai_oauth_callback.py
|
|
20
|
+
integration: ciel_runtime_support/zai_oauth.py
|
|
21
|
+
lifecycle:
|
|
22
|
+
- bind listener before presenting the authorization URL
|
|
23
|
+
- accept only the configured path and matching state
|
|
24
|
+
- return a secret-free browser completion page
|
|
25
|
+
- exchange the callback using the same redirect URI
|
|
26
|
+
- close the one-shot listener after success or timeout
|
|
27
|
+
remote_runtime: >-
|
|
28
|
+
Forward local port 9899 to runtime-host 127.0.0.1:9899 when the browser
|
|
29
|
+
and Ciel Runtime run on different machines.
|
|
30
|
+
|
|
31
|
+
evidence:
|
|
32
|
+
provider_browser_probe:
|
|
33
|
+
redirect_uri: http://localhost:9899/callback
|
|
34
|
+
provider_accepted_redirect_uri: true
|
|
35
|
+
callback_received: true
|
|
36
|
+
state_match: true
|
|
37
|
+
authorization_code_recorded: false
|
|
38
|
+
authorization_plan_screen:
|
|
39
|
+
observed_options:
|
|
40
|
+
- Start Plan
|
|
41
|
+
- Individual Plan
|
|
42
|
+
- API key
|
|
43
|
+
endpoint_urls_verified: false
|
|
44
|
+
conclusion: >-
|
|
45
|
+
The screen proves that three authorization choices are exposed. It does
|
|
46
|
+
not by itself prove that three different API endpoint URLs are used.
|
|
47
|
+
focused_tests:
|
|
48
|
+
status: passed
|
|
49
|
+
count: 18
|
|
50
|
+
final_verification:
|
|
51
|
+
status: passed
|
|
52
|
+
local_install_version: 0.2.28
|
|
53
|
+
callback_uri: http://localhost:9899/callback
|
|
54
|
+
callback_http_status: 200
|
|
55
|
+
callback_url_exact_match: true
|
|
56
|
+
browser_response_contains_authorization_code: false
|
|
57
|
+
listener_port_released_after_completion: true
|
|
58
|
+
full_test_groups:
|
|
59
|
+
unit:
|
|
60
|
+
tests: 1172
|
|
61
|
+
skipped: 44
|
|
62
|
+
router:
|
|
63
|
+
tests: 942
|
|
64
|
+
channel:
|
|
65
|
+
tests: 381
|
|
66
|
+
skipped: 80
|
|
67
|
+
runtime:
|
|
68
|
+
tests: 255
|
|
69
|
+
skipped: 12
|
|
70
|
+
full_test_exit_code: 0
|
package/package.json
CHANGED