@dhf-openclaw/grix 0.4.10 → 0.4.13

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,474 @@
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
+
17
+
18
+ def resolve_default_base_url() -> str:
19
+ return (os.environ.get("GRIX_WEB_BASE_URL", "") or "").strip() or DEFAULT_BASE_URL
20
+
21
+
22
+ def derive_portal_url(raw_base_url: str) -> str:
23
+ base = (raw_base_url or "").strip() or resolve_default_base_url()
24
+ parsed = urllib.parse.urlparse(base)
25
+ if not parsed.scheme or not parsed.netloc:
26
+ raise ValueError(f"Invalid base URL: {base}")
27
+
28
+ path = parsed.path.rstrip("/")
29
+ if path.endswith("/v1"):
30
+ path = path[: -len("/v1")]
31
+
32
+ normalized = parsed._replace(path=path or "/", params="", query="", fragment="")
33
+ return urllib.parse.urlunparse(normalized).rstrip("/") + "/"
34
+
35
+
36
+ class GrixAuthError(RuntimeError):
37
+ def __init__(self, message, status=0, code=-1, payload=None):
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.code = code
41
+ self.payload = payload
42
+
43
+
44
+ def normalize_base_url(raw_base_url: str) -> str:
45
+ base = (raw_base_url or "").strip() or resolve_default_base_url()
46
+ parsed = urllib.parse.urlparse(base)
47
+ if not parsed.scheme or not parsed.netloc:
48
+ raise ValueError(f"Invalid base URL: {base}")
49
+
50
+ path = parsed.path.rstrip("/")
51
+ if not path:
52
+ path = "/v1"
53
+ elif not path.endswith("/v1"):
54
+ path = f"{path}/v1"
55
+
56
+ normalized = parsed._replace(path=path, params="", query="", fragment="")
57
+ return urllib.parse.urlunparse(normalized).rstrip("/")
58
+
59
+
60
+ def request_json(method: str, path: str, base_url: str, body=None, headers=None):
61
+ api_base_url = normalize_base_url(base_url)
62
+ url = f"{api_base_url}{path if path.startswith('/') else '/' + path}"
63
+ data = None
64
+ final_headers = dict(headers or {})
65
+ if body is not None:
66
+ data = json.dumps(body).encode("utf-8")
67
+ final_headers["Content-Type"] = "application/json"
68
+
69
+ req = urllib.request.Request(url=url, data=data, headers=final_headers, method=method)
70
+ try:
71
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_SECONDS) as resp:
72
+ status = getattr(resp, "status", 200)
73
+ raw = resp.read().decode("utf-8")
74
+ except urllib.error.HTTPError as exc:
75
+ status = exc.code
76
+ raw = exc.read().decode("utf-8", errors="replace")
77
+ except urllib.error.URLError as exc:
78
+ raise GrixAuthError(f"network error: {exc.reason}") from exc
79
+
80
+ try:
81
+ payload = json.loads(raw)
82
+ except json.JSONDecodeError as exc:
83
+ raise GrixAuthError(f"invalid json response: {raw[:256]}", status=status) from exc
84
+
85
+ code = int(payload.get("code", -1))
86
+ msg = str(payload.get("msg", "")).strip() or "unknown error"
87
+ if status >= 400 or code != 0:
88
+ raise GrixAuthError(msg, status=status, code=code, payload=payload)
89
+
90
+ return {
91
+ "api_base_url": api_base_url,
92
+ "status": status,
93
+ "data": payload.get("data"),
94
+ "payload": payload,
95
+ }
96
+
97
+
98
+ def maybe_write_captcha_image(b64s: str):
99
+ text = (b64s or "").strip()
100
+ if not text.startswith("data:image/"):
101
+ return ""
102
+
103
+ marker = ";base64,"
104
+ idx = text.find(marker)
105
+ if idx < 0:
106
+ return ""
107
+
108
+ encoded = text[idx + len(marker) :]
109
+ try:
110
+ content = base64.b64decode(encoded)
111
+ except Exception:
112
+ return ""
113
+
114
+ fd, path = tempfile.mkstemp(prefix="grix-captcha-", suffix=".png")
115
+ try:
116
+ with os.fdopen(fd, "wb") as handle:
117
+ handle.write(content)
118
+ except Exception:
119
+ try:
120
+ os.unlink(path)
121
+ except OSError:
122
+ pass
123
+ return ""
124
+ return path
125
+
126
+
127
+ def print_json(payload):
128
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
129
+ sys.stdout.write("\n")
130
+
131
+
132
+ def build_auth_result(action: str, result: dict, base_url: str):
133
+ data = result.get("data") or {}
134
+ user = data.get("user") or {}
135
+ return {
136
+ "ok": True,
137
+ "action": action,
138
+ "api_base_url": result["api_base_url"],
139
+ "access_token": data.get("access_token", ""),
140
+ "refresh_token": data.get("refresh_token", ""),
141
+ "expires_in": data.get("expires_in", 0),
142
+ "user_id": user.get("id", ""),
143
+ "portal_url": derive_portal_url(base_url),
144
+ "data": data,
145
+ }
146
+
147
+
148
+ def build_agent_result(action: str, result: dict):
149
+ data = result.get("data") or {}
150
+ agent_id = str(data.get("id", "")).strip()
151
+ api_endpoint = str(data.get("api_endpoint", "")).strip()
152
+ api_key = str(data.get("api_key", "")).strip()
153
+
154
+ return {
155
+ "ok": True,
156
+ "action": action,
157
+ "api_base_url": result["api_base_url"],
158
+ "agent_id": agent_id,
159
+ "agent_name": data.get("agent_name", ""),
160
+ "provider_type": data.get("provider_type", 0),
161
+ "api_endpoint": api_endpoint,
162
+ "api_key": api_key,
163
+ "api_key_hint": data.get("api_key_hint", ""),
164
+ "session_id": data.get("session_id", ""),
165
+ "handoff": {
166
+ "target_skill": "grix-admin",
167
+ "payload": {
168
+ "agent_id": agent_id,
169
+ "agent_name": data.get("agent_name", ""),
170
+ "api_endpoint": api_endpoint,
171
+ "api_key": api_key,
172
+ },
173
+ },
174
+ "data": data,
175
+ }
176
+
177
+
178
+ def login_with_credentials(base_url: str, account: str, password: str, device_id: str, platform: str):
179
+ result = request_json(
180
+ "POST",
181
+ "/auth/login",
182
+ base_url,
183
+ body={
184
+ "account": account,
185
+ "password": password,
186
+ "device_id": device_id,
187
+ "platform": platform,
188
+ },
189
+ )
190
+ return build_auth_result("login", result, base_url)
191
+
192
+
193
+ def create_api_agent(base_url: str, access_token: str, agent_name: str, avatar_url: str):
194
+ request_body = {
195
+ "agent_name": agent_name.strip(),
196
+ "provider_type": 3,
197
+ "is_main": True,
198
+ }
199
+ normalized_avatar_url = (avatar_url or "").strip()
200
+ if normalized_avatar_url:
201
+ request_body["avatar_url"] = normalized_avatar_url
202
+
203
+ result = request_json(
204
+ "POST",
205
+ "/agents/create",
206
+ base_url,
207
+ body=request_body,
208
+ headers={
209
+ "Authorization": f"Bearer {access_token.strip()}",
210
+ },
211
+ )
212
+ return build_agent_result("create-api-agent", result)
213
+
214
+
215
+ def list_agents(base_url: str, access_token: str):
216
+ result = request_json(
217
+ "GET",
218
+ "/agents/list",
219
+ base_url,
220
+ headers={
221
+ "Authorization": f"Bearer {access_token.strip()}",
222
+ },
223
+ )
224
+ data = result.get("data") or {}
225
+ items = data.get("list") or []
226
+ if not isinstance(items, list):
227
+ items = []
228
+ return items
229
+
230
+
231
+ def rotate_api_agent_key(base_url: str, access_token: str, agent_id: str):
232
+ result = request_json(
233
+ "POST",
234
+ f"/agents/{str(agent_id).strip()}/api/key/rotate",
235
+ base_url,
236
+ body={},
237
+ headers={
238
+ "Authorization": f"Bearer {access_token.strip()}",
239
+ },
240
+ )
241
+ return build_agent_result("rotate-api-agent-key", result)
242
+
243
+
244
+ def find_existing_api_agent(agents, agent_name: str):
245
+ normalized_name = (agent_name or "").strip()
246
+ if not normalized_name:
247
+ return None
248
+
249
+ for item in agents:
250
+ if not isinstance(item, dict):
251
+ continue
252
+ if str(item.get("agent_name", "")).strip() != normalized_name:
253
+ continue
254
+ if int(item.get("provider_type", 0) or 0) != 3:
255
+ continue
256
+ if int(item.get("status", 0) or 0) == 3:
257
+ continue
258
+ return item
259
+ return None
260
+
261
+
262
+ def create_or_reuse_api_agent(
263
+ base_url: str,
264
+ access_token: str,
265
+ agent_name: str,
266
+ avatar_url: str,
267
+ prefer_existing: bool,
268
+ rotate_on_reuse: bool,
269
+ ):
270
+ if prefer_existing:
271
+ agents = list_agents(base_url, access_token)
272
+ existing = find_existing_api_agent(agents, agent_name)
273
+ if existing is not None:
274
+ if not rotate_on_reuse:
275
+ raise GrixAuthError(
276
+ "existing provider_type=3 agent found but rotate-on-reuse is disabled; cannot obtain api_key safely",
277
+ payload={"existing_agent": existing},
278
+ )
279
+ rotated = rotate_api_agent_key(base_url, access_token, str(existing.get("id", "")).strip())
280
+ rotated["source"] = "reused_existing_agent_with_rotated_key"
281
+ rotated["existing_agent"] = existing
282
+ return rotated
283
+
284
+ created = create_api_agent(base_url, access_token, agent_name, avatar_url)
285
+ created["source"] = "created_new_agent"
286
+ return created
287
+
288
+
289
+ def default_device_id(platform: str) -> str:
290
+ normalized_platform = (platform or "").strip() or "web"
291
+ return f"{normalized_platform}_{uuid.uuid4()}"
292
+
293
+
294
+ def handle_fetch_captcha(args):
295
+ result = request_json("GET", "/auth/captcha", args.base_url)
296
+ data = result.get("data") or {}
297
+ image_path = maybe_write_captcha_image(str(data.get("b64s", "")))
298
+ payload = {
299
+ "ok": True,
300
+ "action": "fetch-captcha",
301
+ "api_base_url": result["api_base_url"],
302
+ "captcha_id": data.get("captcha_id", ""),
303
+ "b64s": data.get("b64s", ""),
304
+ }
305
+ if image_path:
306
+ payload["captcha_image_path"] = image_path
307
+ print_json(payload)
308
+
309
+
310
+ def handle_send_email_code(args):
311
+ scene = args.scene.strip()
312
+ payload = {
313
+ "email": args.email.strip(),
314
+ "scene": scene,
315
+ }
316
+
317
+ captcha_id = (args.captcha_id or "").strip()
318
+ captcha_value = (args.captcha_value or "").strip()
319
+ if scene in {"reset", "change_password"}:
320
+ if not captcha_id or not captcha_value:
321
+ raise GrixAuthError("captcha_id and captcha_value are required for reset/change_password")
322
+ if captcha_id:
323
+ payload["captcha_id"] = captcha_id
324
+ if captcha_value:
325
+ payload["captcha_value"] = captcha_value
326
+
327
+ result = request_json(
328
+ "POST",
329
+ "/auth/send-code",
330
+ args.base_url,
331
+ body=payload,
332
+ )
333
+ print_json(
334
+ {
335
+ "ok": True,
336
+ "action": "send-email-code",
337
+ "api_base_url": result["api_base_url"],
338
+ "data": result.get("data"),
339
+ }
340
+ )
341
+
342
+
343
+ def handle_register(args):
344
+ platform = (args.platform or "").strip() or "web"
345
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
346
+ result = request_json(
347
+ "POST",
348
+ "/auth/register",
349
+ args.base_url,
350
+ body={
351
+ "email": args.email.strip(),
352
+ "password": args.password.strip(),
353
+ "email_code": args.email_code.strip(),
354
+ "device_id": device_id,
355
+ "platform": platform,
356
+ },
357
+ )
358
+ print_json(build_auth_result("register", result, args.base_url))
359
+
360
+
361
+ def handle_login(args):
362
+ account = (args.email or args.account or "").strip()
363
+ if not account:
364
+ raise GrixAuthError("either --email or --account is required")
365
+
366
+ platform = (args.platform or "").strip() or "web"
367
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
368
+ print_json(
369
+ login_with_credentials(
370
+ args.base_url,
371
+ account,
372
+ args.password.strip(),
373
+ device_id,
374
+ platform,
375
+ )
376
+ )
377
+
378
+
379
+ def handle_create_api_agent(args):
380
+ print_json(
381
+ create_or_reuse_api_agent(
382
+ args.base_url,
383
+ args.access_token.strip(),
384
+ args.agent_name.strip(),
385
+ args.avatar_url,
386
+ not bool(args.no_reuse_existing_agent),
387
+ not bool(args.no_rotate_key_on_reuse),
388
+ )
389
+ )
390
+
391
+
392
+ def build_parser():
393
+ parser = argparse.ArgumentParser(description="Grix public auth API helper")
394
+ parser.add_argument(
395
+ "--base-url",
396
+ default=resolve_default_base_url(),
397
+ help="Grix web base URL (defaults to GRIX_WEB_BASE_URL or https://grix.dhf.pub)",
398
+ )
399
+
400
+ subparsers = parser.add_subparsers(dest="action", required=True)
401
+
402
+ fetch_captcha = subparsers.add_parser("fetch-captcha", help="Fetch captcha image")
403
+ fetch_captcha.set_defaults(handler=handle_fetch_captcha)
404
+
405
+ send_email_code = subparsers.add_parser("send-email-code", help="Send email verification code")
406
+ send_email_code.add_argument("--email", required=True)
407
+ send_email_code.add_argument("--scene", required=True, choices=["register", "reset", "change_password"])
408
+ send_email_code.add_argument("--captcha-id", default="")
409
+ send_email_code.add_argument("--captcha-value", default="")
410
+ send_email_code.set_defaults(handler=handle_send_email_code)
411
+
412
+ register = subparsers.add_parser("register", help="Register by email verification code")
413
+ register.add_argument("--email", required=True)
414
+ register.add_argument("--password", required=True)
415
+ register.add_argument("--email-code", required=True)
416
+ register.add_argument("--device-id", default="")
417
+ register.add_argument("--platform", default="web")
418
+ register.set_defaults(handler=handle_register)
419
+
420
+ login = subparsers.add_parser("login", help="Login and obtain tokens")
421
+ login_identity = login.add_mutually_exclusive_group(required=True)
422
+ login_identity.add_argument("--account")
423
+ login_identity.add_argument("--email")
424
+ login.add_argument("--password", required=True)
425
+ login.add_argument("--device-id", default="")
426
+ login.add_argument("--platform", default="web")
427
+ login.set_defaults(handler=handle_login)
428
+
429
+ create_api_agent_parser = subparsers.add_parser(
430
+ "create-api-agent",
431
+ help="Create a provider_type=3 API agent with a user access token",
432
+ )
433
+ create_api_agent_parser.add_argument("--access-token", required=True)
434
+ create_api_agent_parser.add_argument("--agent-name", required=True)
435
+ create_api_agent_parser.add_argument("--avatar-url", default="")
436
+ create_api_agent_parser.add_argument("--no-reuse-existing-agent", action="store_true")
437
+ create_api_agent_parser.add_argument("--no-rotate-key-on-reuse", action="store_true")
438
+ create_api_agent_parser.set_defaults(handler=handle_create_api_agent)
439
+
440
+ return parser
441
+
442
+
443
+ def main():
444
+ parser = build_parser()
445
+ args = parser.parse_args()
446
+ try:
447
+ args.handler(args)
448
+ except GrixAuthError as exc:
449
+ print_json(
450
+ {
451
+ "ok": False,
452
+ "action": args.action,
453
+ "status": exc.status,
454
+ "code": exc.code,
455
+ "error": str(exc),
456
+ "payload": exc.payload,
457
+ }
458
+ )
459
+ raise SystemExit(1)
460
+ except Exception as exc:
461
+ print_json(
462
+ {
463
+ "ok": False,
464
+ "action": args.action,
465
+ "status": 0,
466
+ "code": -1,
467
+ "error": str(exc),
468
+ }
469
+ )
470
+ raise SystemExit(1)
471
+
472
+
473
+ if __name__ == "__main__":
474
+ main()
@@ -1,85 +0,0 @@
1
- ---
2
- name: grix-agent-admin
3
- description: 通过 Grix Agent API 协议创建 `provider_type=3` 的 API agent,并直接完成本地 OpenClaw agent 与 Grix 渠道绑定配置(默认直接应用并返回结果)。
4
- ---
5
-
6
- # Grix Agent Admin
7
-
8
- Create a remote `provider_type=3` API agent, then complete local OpenClaw agent + grix channel binding in one flow.
9
-
10
- ## Security + Auth Path
11
-
12
- 1. This skill does **not** ask the user for website account/password.
13
- 2. Remote create action uses local `channels.grix` credentials and `Authorization: Bearer <agent_api_key>`.
14
- 3. Local OpenClaw config is handled by `scripts/grix_agent_bind.py`.
15
-
16
- ## Required Input
17
-
18
- 1. `agentName` (required): regex `^[a-z][a-z0-9-]{2,31}$`
19
- 2. `describeMessageTool` (required): must contain non-empty `actions`
20
- 3. `accountId` (optional)
21
- 4. `avatarUrl` (optional)
22
-
23
- ## Full Workflow
24
-
25
- ### A. Create Remote API Agent
26
-
27
- 1. Validate `agentName` and `describeMessageTool`.
28
- 2. Call `grix_agent_admin` once.
29
- 3. Read result fields: `id`, `agent_name`, `api_endpoint`, `api_key`, `api_key_hint`.
30
-
31
- ### B. Apply Local OpenClaw Binding Directly
32
-
33
- Run with `--apply` directly:
34
-
35
- ```bash
36
- scripts/grix_agent_bind.py configure-local-openclaw \
37
- --agent-name <agent_name> \
38
- --agent-id <agent_id> \
39
- --api-endpoint '<api_endpoint>' \
40
- --api-key '<api_key>' \
41
- --apply
42
- ```
43
-
44
- This applies:
45
-
46
- 1. upsert `agents.list` for `<agent_name>`
47
- 2. upsert `channels.grix.accounts.<agent_name>`
48
- 3. upsert `bindings` route to `channel=grix`, `accountId=<agent_name>`
49
- 4. ensure required tools (`message`, `grix_group`, `grix_agent_admin`)
50
- 5. create workspace defaults under `~/.openclaw/workspace-<agent_name>/`
51
- 6. run `openclaw gateway restart`
52
-
53
- ### C. Optional Verification
54
-
55
- If you need explicit post-check state, run:
56
-
57
- ```bash
58
- scripts/grix_agent_bind.py inspect-local-openclaw --agent-name <agent_name>
59
- ```
60
-
61
- ## Guardrails
62
-
63
- 1. Never ask user for website account/password.
64
- 2. Treat remote create as non-idempotent; do not auto-retry without confirmation.
65
- 3. Keep full `api_key` one-time only; do not repeatedly echo it.
66
- 4. Do not claim success before apply command returns success.
67
-
68
- ## Error Handling Rules
69
-
70
- 1. invalid name: ask user for a valid English lowercase name.
71
- 2. `403/20011`: ask owner to grant `agent.api.create` scope.
72
- 3. `401/10001`: verify local `agent_api_key` / grix account config.
73
- 4. `409/20002`: ask for another agent name.
74
- 5. local apply failed: return concrete failed command/result and stop.
75
-
76
- ## Response Style
77
-
78
- 1. Report two stages separately: remote create status + local binding status.
79
- 2. Include created `agent_id`, `agent_name`, `api_endpoint`, `api_key_hint`.
80
- 3. Clearly state local config has been applied (or failed with concrete reason).
81
-
82
- ## References
83
-
84
- 1. Load [references/api-contract.md](references/api-contract.md).
85
- 2. Use [scripts/grix_agent_bind.py](scripts/grix_agent_bind.py) for local binding apply.
@@ -1,4 +0,0 @@
1
- interface:
2
- display_name: "Grix Agent Admin"
3
- short_description: "Create Grix API agents and finish local OpenClaw binding."
4
- default_prompt: "Use this skill when users ask to create a new Grix API agent and finish OpenClaw binding. Never ask for website account/password. Validate `agentName` and require `describeMessageTool` (`actions` must be non-empty), then call `grix_agent_admin` once. After successful create, directly run `scripts/grix_agent_bind.py configure-local-openclaw ... --apply` and return final local binding result (success or exact failure reason)."
@@ -1,4 +0,0 @@
1
- interface:
2
- display_name: "Grix Group Governance"
3
- short_description: "Use the typed grix_group tool for Grix group operations."
4
- default_prompt: "Use this skill when users ask to create, inspect, update, or dissolve Grix groups. Validate parameters, call grix_group exactly once per action, and return clear remediation for scope/auth/parameter failures."
@@ -1,4 +0,0 @@
1
- version: 1
2
- agent:
3
- name: grix-query
4
- default_prompt: "Use this skill when users ask to find Grix contacts, locate sessions, or inspect a known session's message history. Validate required parameters, call grix_query exactly once per action, and do not fabricate a sessionId."