@dhf-openclaw/grix 0.4.9 → 0.4.11

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,71 @@
1
+ # API Contract
2
+
3
+ ## Responsibility Boundary
4
+
5
+ 1. `grix-register` 仅负责账号鉴权与云端 `provider_type=3` Agent 参数产出。
6
+ 2. 本技能不负责本地 OpenClaw 配置。
7
+ 3. 本地配置由 `grix-admin` 接手。
8
+
9
+ ## Base
10
+
11
+ 1. Website: `https://grix.dhf.pub/`
12
+ 2. Public Grix API base: `https://grix.dhf.pub/v1`
13
+
14
+ ## Route Mapping
15
+
16
+ ### Agent bootstrap action
17
+
18
+ | Action | Method | Route | Auth |
19
+ |---|---|---|---|
20
+ | `create-api-agent` | `POST` | `/agents/create` | `Authorization: Bearer <access_token>` |
21
+ | `list-agents` (internal helper) | `GET` | `/agents/list` | `Authorization: Bearer <access_token>` |
22
+ | `rotate-api-agent-key` (internal helper) | `POST` | `/agents/:id/api/key/rotate` | `Authorization: Bearer <access_token>` |
23
+
24
+ ## Payloads
25
+
26
+ ### `create-api-agent`
27
+
28
+ ```json
29
+ {
30
+ "agent_name": "grix-main",
31
+ "provider_type": 3
32
+ }
33
+ ```
34
+
35
+ `provider_type=3` means Agent API type.
36
+
37
+ ## Reuse flow
38
+
39
+ When the same-name `provider_type=3` agent already exists, the skill should:
40
+
41
+ 1. read `/agents/list`
42
+ 2. find the exact-name API agent
43
+ 3. rotate its key through `/agents/:id/api/key/rotate`
44
+ 4. reuse the returned `api_endpoint` and fresh `api_key`
45
+
46
+ ## Success Highlights
47
+
48
+ ### `create-api-agent`
49
+
50
+ The bundled script lifts these fields to the top level:
51
+
52
+ 1. `agent_id`
53
+ 2. `agent_name`
54
+ 3. `provider_type`
55
+ 4. `api_endpoint`
56
+ 5. `api_key`
57
+ 6. `api_key_hint`
58
+ 7. `session_id`
59
+
60
+ ## Common Errors
61
+
62
+ 1. create-agent or rotate-key returns missing `api_endpoint` or `api_key`
63
+
64
+ ## Handoff
65
+
66
+ 成功后输出这些字段,直接交给 `grix-admin`:
67
+
68
+ 1. `agent_id`
69
+ 2. `agent_name`
70
+ 3. `api_endpoint`
71
+ 4. `api_key`
@@ -0,0 +1,26 @@
1
+ # Grix Concepts
2
+
3
+ ## Canonical Explanation
4
+
5
+ 这个插件接入是为了在 `https://grix.dhf.pub/` 网站管理 OpenClaw,并支持移动端 PWA 页面。
6
+
7
+ ## Feature Highlights
8
+
9
+ 1. `grix-register` 负责初次账号准备与首个 agent 参数生成
10
+ 2. `grix-admin` 负责 OpenClaw 本地配置与后续管理
11
+ 3. 两者串联后,用户可在 `https://grix.dhf.pub/` 使用和管理
12
+
13
+ ## Default User-Facing Framing
14
+
15
+ ### One sentence
16
+
17
+ `grix-register` 只做“注册账号并拿到第一个 agent 参数”,本地配置统一交给 `grix-admin`。
18
+
19
+ ### Short paragraph
20
+
21
+ `grix-register` 只负责初次安装中的云端准备:注册/登录账号并生成第一个 `provider_type=3` agent 参数;随后必须把参数交给 `grix-admin`,由 `grix-admin` 负责本地 OpenClaw 配置。
22
+
23
+ ## After Setup
24
+
25
+ 1. `grix-register` 产出参数后,直接交接给 `grix-admin`。
26
+ 2. `grix-register` 不执行任何本地配置动作。
@@ -0,0 +1,24 @@
1
+ # Handoff Contract to grix-admin
2
+
3
+ ## Purpose
4
+
5
+ `grix-register` 完成账号与首个 Agent 参数准备后,统一把本地配置工作交给 `grix-admin`。
6
+
7
+ ## Required Payload
8
+
9
+ ```json
10
+ {
11
+ "mode": "bind-local",
12
+ "agent_name": "grix-main",
13
+ "agent_id": "2029786829095440384",
14
+ "api_endpoint": "wss://grix.dhf.pub/v1/agent-api/ws?agent_id=2029786829095440384",
15
+ "api_key": "ak_xxx"
16
+ }
17
+ ```
18
+
19
+ ## Rules
20
+
21
+ 1. `mode` 固定为 `bind-local`。
22
+ 2. `agent_name`、`agent_id`、`api_endpoint`、`api_key` 必填。
23
+ 3. `grix-register` 只负责生成以上参数,不执行本地配置命令。
24
+ 4. 本地写入、插件处理、工具权限、gateway 重启都由 `grix-admin` 负责。
@@ -0,0 +1,6 @@
1
+ # OpenClaw Setup Ownership
2
+
3
+ `grix-register` 不负责 OpenClaw 本地配置。
4
+ 所有 OpenClaw 配置相关动作(插件安装、channel 写入、tools 权限、gateway 重启)都属于 `grix-admin`。
5
+
6
+ 本目录保留此文件仅用于职责说明,避免误用。
@@ -0,0 +1,25 @@
1
+ # User Replies
2
+
3
+ ## One-liner Pitch
4
+
5
+ 我会先帮你完成注册并拿到第一个 agent 参数,然后交给 `grix-admin` 完成本地配置。
6
+
7
+ ## Short Intro
8
+
9
+ `grix-register` 只负责账号和云端参数准备,不改本地配置;本地配置由 `grix-admin` 接手。
10
+
11
+ ## Ready Reply
12
+
13
+ 账号和首个 agent 参数已经准备好,接下来我会把参数交给 `grix-admin` 做本地配置。
14
+
15
+ ## Main Ready, Admin Pending Reply
16
+
17
+ `grix-register` 阶段已完成,我现在只做参数移交,后续本地配置请由 `grix-admin` 继续。
18
+
19
+ ## Configured Now Reply
20
+
21
+ 参数已交接给 `grix-admin`,接下来由它完成本地配置。
22
+
23
+ ## Needs Setup Reply
24
+
25
+ 当前仍在 `grix-register` 阶段,我会继续完成注册并拿到第一个 agent 参数。
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+
13
+
14
+ DEFAULT_BASE_URL = "https://grix.dhf.pub"
15
+ DEFAULT_TIMEOUT_SECONDS = 15
16
+ DEFAULT_PORTAL_URL = "https://grix.dhf.pub/"
17
+
18
+
19
+ class GrixAuthError(RuntimeError):
20
+ def __init__(self, message, status=0, code=-1, payload=None):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+ self.payload = payload
25
+
26
+
27
+ def normalize_base_url(raw_base_url: str) -> str:
28
+ base = (raw_base_url or "").strip() or DEFAULT_BASE_URL
29
+ parsed = urllib.parse.urlparse(base)
30
+ if not parsed.scheme or not parsed.netloc:
31
+ raise ValueError(f"Invalid base URL: {base}")
32
+
33
+ path = parsed.path.rstrip("/")
34
+ if not path:
35
+ path = "/v1"
36
+ elif not path.endswith("/v1"):
37
+ path = f"{path}/v1"
38
+
39
+ normalized = parsed._replace(path=path, params="", query="", fragment="")
40
+ return urllib.parse.urlunparse(normalized).rstrip("/")
41
+
42
+
43
+ def request_json(method: str, path: str, base_url: str, body=None, headers=None):
44
+ api_base_url = normalize_base_url(base_url)
45
+ url = f"{api_base_url}{path if path.startswith('/') else '/' + path}"
46
+ data = None
47
+ final_headers = dict(headers or {})
48
+ if body is not None:
49
+ data = json.dumps(body).encode("utf-8")
50
+ final_headers["Content-Type"] = "application/json"
51
+
52
+ req = urllib.request.Request(url=url, data=data, headers=final_headers, method=method)
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_SECONDS) as resp:
55
+ status = getattr(resp, "status", 200)
56
+ raw = resp.read().decode("utf-8")
57
+ except urllib.error.HTTPError as exc:
58
+ status = exc.code
59
+ raw = exc.read().decode("utf-8", errors="replace")
60
+ except urllib.error.URLError as exc:
61
+ raise GrixAuthError(f"network error: {exc.reason}") from exc
62
+
63
+ try:
64
+ payload = json.loads(raw)
65
+ except json.JSONDecodeError as exc:
66
+ raise GrixAuthError(f"invalid json response: {raw[:256]}", status=status) from exc
67
+
68
+ code = int(payload.get("code", -1))
69
+ msg = str(payload.get("msg", "")).strip() or "unknown error"
70
+ if status >= 400 or code != 0:
71
+ raise GrixAuthError(msg, status=status, code=code, payload=payload)
72
+
73
+ return {
74
+ "api_base_url": api_base_url,
75
+ "status": status,
76
+ "data": payload.get("data"),
77
+ "payload": payload,
78
+ }
79
+
80
+
81
+ def maybe_write_captcha_image(b64s: str):
82
+ text = (b64s or "").strip()
83
+ if not text.startswith("data:image/"):
84
+ return ""
85
+
86
+ marker = ";base64,"
87
+ idx = text.find(marker)
88
+ if idx < 0:
89
+ return ""
90
+
91
+ encoded = text[idx + len(marker) :]
92
+ try:
93
+ content = base64.b64decode(encoded)
94
+ except Exception:
95
+ return ""
96
+
97
+ fd, path = tempfile.mkstemp(prefix="grix-captcha-", suffix=".png")
98
+ try:
99
+ with os.fdopen(fd, "wb") as handle:
100
+ handle.write(content)
101
+ except Exception:
102
+ try:
103
+ os.unlink(path)
104
+ except OSError:
105
+ pass
106
+ return ""
107
+ return path
108
+
109
+
110
+ def print_json(payload):
111
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
112
+ sys.stdout.write("\n")
113
+
114
+
115
+ def build_auth_result(action: str, result: dict):
116
+ data = result.get("data") or {}
117
+ user = data.get("user") or {}
118
+ return {
119
+ "ok": True,
120
+ "action": action,
121
+ "api_base_url": result["api_base_url"],
122
+ "access_token": data.get("access_token", ""),
123
+ "refresh_token": data.get("refresh_token", ""),
124
+ "expires_in": data.get("expires_in", 0),
125
+ "user_id": user.get("id", ""),
126
+ "portal_url": DEFAULT_PORTAL_URL,
127
+ "data": data,
128
+ }
129
+
130
+
131
+ def build_agent_result(action: str, result: dict):
132
+ data = result.get("data") or {}
133
+ agent_id = str(data.get("id", "")).strip()
134
+ api_endpoint = str(data.get("api_endpoint", "")).strip()
135
+ api_key = str(data.get("api_key", "")).strip()
136
+
137
+ return {
138
+ "ok": True,
139
+ "action": action,
140
+ "api_base_url": result["api_base_url"],
141
+ "agent_id": agent_id,
142
+ "agent_name": data.get("agent_name", ""),
143
+ "provider_type": data.get("provider_type", 0),
144
+ "api_endpoint": api_endpoint,
145
+ "api_key": api_key,
146
+ "api_key_hint": data.get("api_key_hint", ""),
147
+ "session_id": data.get("session_id", ""),
148
+ "handoff": {
149
+ "target_skill": "grix-admin",
150
+ "payload": {
151
+ "agent_id": agent_id,
152
+ "agent_name": data.get("agent_name", ""),
153
+ "api_endpoint": api_endpoint,
154
+ "api_key": api_key,
155
+ },
156
+ },
157
+ "data": data,
158
+ }
159
+
160
+
161
+ def login_with_credentials(base_url: str, account: str, password: str, device_id: str, platform: str):
162
+ result = request_json(
163
+ "POST",
164
+ "/auth/login",
165
+ base_url,
166
+ body={
167
+ "account": account,
168
+ "password": password,
169
+ "device_id": device_id,
170
+ "platform": platform,
171
+ },
172
+ )
173
+ return build_auth_result("login", result)
174
+
175
+
176
+ def create_api_agent(base_url: str, access_token: str, agent_name: str, avatar_url: str):
177
+ request_body = {
178
+ "agent_name": agent_name.strip(),
179
+ "provider_type": 3,
180
+ "is_main": True,
181
+ }
182
+ normalized_avatar_url = (avatar_url or "").strip()
183
+ if normalized_avatar_url:
184
+ request_body["avatar_url"] = normalized_avatar_url
185
+
186
+ result = request_json(
187
+ "POST",
188
+ "/agents/create",
189
+ base_url,
190
+ body=request_body,
191
+ headers={
192
+ "Authorization": f"Bearer {access_token.strip()}",
193
+ },
194
+ )
195
+ return build_agent_result("create-api-agent", result)
196
+
197
+
198
+ def list_agents(base_url: str, access_token: str):
199
+ result = request_json(
200
+ "GET",
201
+ "/agents/list",
202
+ base_url,
203
+ headers={
204
+ "Authorization": f"Bearer {access_token.strip()}",
205
+ },
206
+ )
207
+ data = result.get("data") or {}
208
+ items = data.get("list") or []
209
+ if not isinstance(items, list):
210
+ items = []
211
+ return items
212
+
213
+
214
+ def rotate_api_agent_key(base_url: str, access_token: str, agent_id: str):
215
+ result = request_json(
216
+ "POST",
217
+ f"/agents/{str(agent_id).strip()}/api/key/rotate",
218
+ base_url,
219
+ body={},
220
+ headers={
221
+ "Authorization": f"Bearer {access_token.strip()}",
222
+ },
223
+ )
224
+ return build_agent_result("rotate-api-agent-key", result)
225
+
226
+
227
+ def find_existing_api_agent(agents, agent_name: str):
228
+ normalized_name = (agent_name or "").strip()
229
+ if not normalized_name:
230
+ return None
231
+
232
+ for item in agents:
233
+ if not isinstance(item, dict):
234
+ continue
235
+ if str(item.get("agent_name", "")).strip() != normalized_name:
236
+ continue
237
+ if int(item.get("provider_type", 0) or 0) != 3:
238
+ continue
239
+ if int(item.get("status", 0) or 0) == 3:
240
+ continue
241
+ return item
242
+ return None
243
+
244
+
245
+ def create_or_reuse_api_agent(
246
+ base_url: str,
247
+ access_token: str,
248
+ agent_name: str,
249
+ avatar_url: str,
250
+ prefer_existing: bool,
251
+ rotate_on_reuse: bool,
252
+ ):
253
+ if prefer_existing:
254
+ agents = list_agents(base_url, access_token)
255
+ existing = find_existing_api_agent(agents, agent_name)
256
+ if existing is not None:
257
+ if not rotate_on_reuse:
258
+ raise GrixAuthError(
259
+ "existing provider_type=3 agent found but rotate-on-reuse is disabled; cannot obtain api_key safely",
260
+ payload={"existing_agent": existing},
261
+ )
262
+ rotated = rotate_api_agent_key(base_url, access_token, str(existing.get("id", "")).strip())
263
+ rotated["source"] = "reused_existing_agent_with_rotated_key"
264
+ rotated["existing_agent"] = existing
265
+ return rotated
266
+
267
+ created = create_api_agent(base_url, access_token, agent_name, avatar_url)
268
+ created["source"] = "created_new_agent"
269
+ return created
270
+
271
+
272
+ def default_device_id(platform: str) -> str:
273
+ normalized_platform = (platform or "").strip() or "web"
274
+ return f"{normalized_platform}_{uuid.uuid4()}"
275
+
276
+
277
+ def handle_fetch_captcha(args):
278
+ result = request_json("GET", "/auth/captcha", args.base_url)
279
+ data = result.get("data") or {}
280
+ image_path = maybe_write_captcha_image(str(data.get("b64s", "")))
281
+ payload = {
282
+ "ok": True,
283
+ "action": "fetch-captcha",
284
+ "api_base_url": result["api_base_url"],
285
+ "captcha_id": data.get("captcha_id", ""),
286
+ "b64s": data.get("b64s", ""),
287
+ }
288
+ if image_path:
289
+ payload["captcha_image_path"] = image_path
290
+ print_json(payload)
291
+
292
+
293
+ def handle_send_email_code(args):
294
+ scene = args.scene.strip()
295
+ payload = {
296
+ "email": args.email.strip(),
297
+ "scene": scene,
298
+ }
299
+
300
+ captcha_id = (args.captcha_id or "").strip()
301
+ captcha_value = (args.captcha_value or "").strip()
302
+ if scene in {"reset", "change_password"}:
303
+ if not captcha_id or not captcha_value:
304
+ raise GrixAuthError("captcha_id and captcha_value are required for reset/change_password")
305
+ if captcha_id:
306
+ payload["captcha_id"] = captcha_id
307
+ if captcha_value:
308
+ payload["captcha_value"] = captcha_value
309
+
310
+ result = request_json(
311
+ "POST",
312
+ "/auth/send-code",
313
+ args.base_url,
314
+ body=payload,
315
+ )
316
+ print_json(
317
+ {
318
+ "ok": True,
319
+ "action": "send-email-code",
320
+ "api_base_url": result["api_base_url"],
321
+ "data": result.get("data"),
322
+ }
323
+ )
324
+
325
+
326
+ def handle_register(args):
327
+ platform = (args.platform or "").strip() or "web"
328
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
329
+ result = request_json(
330
+ "POST",
331
+ "/auth/register",
332
+ args.base_url,
333
+ body={
334
+ "email": args.email.strip(),
335
+ "password": args.password.strip(),
336
+ "email_code": args.email_code.strip(),
337
+ "device_id": device_id,
338
+ "platform": platform,
339
+ },
340
+ )
341
+ print_json(build_auth_result("register", result))
342
+
343
+
344
+ def handle_login(args):
345
+ account = (args.email or args.account or "").strip()
346
+ if not account:
347
+ raise GrixAuthError("either --email or --account is required")
348
+
349
+ platform = (args.platform or "").strip() or "web"
350
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
351
+ print_json(
352
+ login_with_credentials(
353
+ args.base_url,
354
+ account,
355
+ args.password.strip(),
356
+ device_id,
357
+ platform,
358
+ )
359
+ )
360
+
361
+
362
+ def handle_create_api_agent(args):
363
+ print_json(
364
+ create_or_reuse_api_agent(
365
+ args.base_url,
366
+ args.access_token.strip(),
367
+ args.agent_name.strip(),
368
+ args.avatar_url,
369
+ not bool(args.no_reuse_existing_agent),
370
+ not bool(args.no_rotate_key_on_reuse),
371
+ )
372
+ )
373
+
374
+
375
+ def build_parser():
376
+ parser = argparse.ArgumentParser(description="Grix public auth API helper")
377
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Grix web base URL")
378
+
379
+ subparsers = parser.add_subparsers(dest="action", required=True)
380
+
381
+ fetch_captcha = subparsers.add_parser("fetch-captcha", help="Fetch captcha image")
382
+ fetch_captcha.set_defaults(handler=handle_fetch_captcha)
383
+
384
+ send_email_code = subparsers.add_parser("send-email-code", help="Send email verification code")
385
+ send_email_code.add_argument("--email", required=True)
386
+ send_email_code.add_argument("--scene", required=True, choices=["register", "reset", "change_password"])
387
+ send_email_code.add_argument("--captcha-id", default="")
388
+ send_email_code.add_argument("--captcha-value", default="")
389
+ send_email_code.set_defaults(handler=handle_send_email_code)
390
+
391
+ register = subparsers.add_parser("register", help="Register by email verification code")
392
+ register.add_argument("--email", required=True)
393
+ register.add_argument("--password", required=True)
394
+ register.add_argument("--email-code", required=True)
395
+ register.add_argument("--device-id", default="")
396
+ register.add_argument("--platform", default="web")
397
+ register.set_defaults(handler=handle_register)
398
+
399
+ login = subparsers.add_parser("login", help="Login and obtain tokens")
400
+ login_identity = login.add_mutually_exclusive_group(required=True)
401
+ login_identity.add_argument("--account")
402
+ login_identity.add_argument("--email")
403
+ login.add_argument("--password", required=True)
404
+ login.add_argument("--device-id", default="")
405
+ login.add_argument("--platform", default="web")
406
+ login.set_defaults(handler=handle_login)
407
+
408
+ create_api_agent_parser = subparsers.add_parser(
409
+ "create-api-agent",
410
+ help="Create a provider_type=3 API agent with a user access token",
411
+ )
412
+ create_api_agent_parser.add_argument("--access-token", required=True)
413
+ create_api_agent_parser.add_argument("--agent-name", required=True)
414
+ create_api_agent_parser.add_argument("--avatar-url", default="")
415
+ create_api_agent_parser.add_argument("--no-reuse-existing-agent", action="store_true")
416
+ create_api_agent_parser.add_argument("--no-rotate-key-on-reuse", action="store_true")
417
+ create_api_agent_parser.set_defaults(handler=handle_create_api_agent)
418
+
419
+ return parser
420
+
421
+
422
+ def main():
423
+ parser = build_parser()
424
+ args = parser.parse_args()
425
+ try:
426
+ args.handler(args)
427
+ except GrixAuthError as exc:
428
+ print_json(
429
+ {
430
+ "ok": False,
431
+ "action": args.action,
432
+ "status": exc.status,
433
+ "code": exc.code,
434
+ "error": str(exc),
435
+ "payload": exc.payload,
436
+ }
437
+ )
438
+ raise SystemExit(1)
439
+ except Exception as exc:
440
+ print_json(
441
+ {
442
+ "ok": False,
443
+ "action": args.action,
444
+ "status": 0,
445
+ "code": -1,
446
+ "error": str(exc),
447
+ }
448
+ )
449
+ raise SystemExit(1)
450
+
451
+
452
+ if __name__ == "__main__":
453
+ main()