arkclaw-webchat-cli 0.1.0__py3-none-any.whl

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,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: arkclaw-webchat-cli
3
+ Version: 0.1.0
4
+ Summary: CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK.
5
+ Author: ArkClaw Team
6
+ Keywords: arkclaw,cli,ee,openclaw,sso,sts
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: typer>=0.12.0
17
+ Requires-Dist: websockets>=12.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.4; extra == 'dev'
20
+ Requires-Dist: ruff>=0.6; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ee-claw
24
+
25
+ A tiny CLI to chat with a **Claw** in an **ArkClaw EE** space from your terminal —
26
+ authenticated by your existing **enterprise SSO** session, with **zero permanent
27
+ AK/SK** ever stored.
28
+
29
+ ```bash
30
+ pip install ee-claw
31
+
32
+ arkclaw login https://<space>.arkclaw-enterprise-bj.volceapi.com/
33
+ arkclaw chat
34
+ ```
35
+
36
+ That's it. `login` reuses the SSO session your browser already holds for the
37
+ space; `chat` talks to the Claw you last had open there.
38
+
39
+ ## How it works
40
+
41
+ ```
42
+ Chrome login (id_token) → STS AssumeRoleWithOIDC → temporary creds
43
+ → GetClawInstanceChatToken → ChatToken
44
+ → OpenClaw WebSocket → chat
45
+ ```
46
+
47
+ - **`login <space-url>`** reads the `id_token` Chrome already holds for the space
48
+ (you must be logged in there), validates it by exchanging it for **temporary**
49
+ credentials via Volcengine STS, and caches the session in
50
+ `~/.arkclaw/ee_login.json` (mode `0600`). No browser is opened, nothing is
51
+ pasted, **no permanent AK/SK is ever written**.
52
+ - **`chat`** uses the cached login to mint a one-time `ChatToken`
53
+ (`GetClawInstanceChatToken`) and opens an OpenClaw WebSocket. Without
54
+ `--clawid` it uses the claw you most recently opened in the browser (read from
55
+ Chrome history); pass `--clawid ci-...` to target a specific one.
56
+
57
+ ## Admin setup (once per space)
58
+
59
+ The CLI needs one piece of space-level configuration: the **STS role** whose
60
+ trust policy accepts the space's enterprise-SSO identity pool and whose
61
+ permission policy allows `arkclaw:GetClawInstanceChatToken`. Provide it via
62
+ (highest precedence first):
63
+
64
+ 1. `--role-trn trn:iam::<account>:role/<name>`
65
+ 2. `ARKCLAW_ROLE_TRN` environment variable
66
+ 3. the space serving `GET <space-url>/.well-known/arkclaw-cli` →
67
+ `{"region": ..., "role_trn": ..., "provider_trn": ...}` (then the user types
68
+ only the URL)
69
+
70
+ Nothing is hardcoded per space. Region is derived from the URL (override with
71
+ `--region`); the OIDC provider is inferred from the token issuer (override with
72
+ `--provider-trn`). If no role can be resolved, `login` fails with
73
+ `ARKCLAW_E_UNCONFIGURED`.
74
+
75
+ ## Security
76
+
77
+ The role is a least-privilege bridge: enterprise SSO identity → **1-hour**
78
+ temporary credentials that can do exactly **one** thing
79
+ (`GetClawInstanceChatToken`) and nothing else in the account. See the error
80
+ codes (`ARKCLAW_E_NOLOGIN`, `ARKCLAW_E_STS`, `ARKCLAW_E_UNCONFIGURED`, …) for
81
+ clear diagnostics.
82
+
83
+ ## Scope
84
+
85
+ - Platform: **Chrome on macOS/Linux** (reads Chrome's Local Storage + history).
86
+ - This is the ArkClaw EE companion CLI; the general-purpose public SDK is
87
+ `arkclaw-sdk` (standard OIDC login + a2a chat) and lives separately.
@@ -0,0 +1,7 @@
1
+ ee_claw/__init__.py,sha256=WhvO5inhRiVQrBZ_4hrEY6KGJ-TvrPcA6kBdAIS7jRc,508
2
+ ee_claw/cli.py,sha256=7yJZRvaZEXQuef6Uvihrx5_tlj-KEEPCVqbq0lF9yfY,2194
3
+ ee_claw/core.py,sha256=_EjPd6ke7zewEooZeDyvo9FNlXIB6oQ6lQqlcxPWZKI,19429
4
+ arkclaw_webchat_cli-0.1.0.dist-info/METADATA,sha256=nVWoJUiAdjbE8bGHSO7XZ9xvvRw3f03rDPjK-NhEm7Y,3505
5
+ arkclaw_webchat_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ arkclaw_webchat_cli-0.1.0.dist-info/entry_points.txt,sha256=L0Hnlqj7BYjaDUlcnVgUQGjTCVrsc0GCsDF2Ho1y0v0,53
7
+ arkclaw_webchat_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ arkclaw-webchat = ee_claw.cli:main
ee_claw/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ee-claw — a tiny CLI to chat with an ArkClaw EE space's Claw over enterprise
2
+ SSO, with zero permanent AK/SK.
3
+
4
+ arkclaw login <space-url> # reuse the browser's SSO session → STS temp creds
5
+ arkclaw chat # talk to the Claw you last had open
6
+
7
+ See :mod:`ee_claw.core` for the mechanism (Chrome login read → STS
8
+ AssumeRoleWithOIDC → GetClawInstanceChatToken → OpenClaw WebSocket).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = ["__version__"]
ee_claw/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """The ``arkclaw`` command: ``login <space-url>`` and ``chat``.
2
+
3
+ Thin wrapper over :mod:`ee_claw.core` — argument parsing only, no logic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from ee_claw import core
11
+
12
+ app = typer.Typer(
13
+ add_completion=False,
14
+ no_args_is_help=True,
15
+ help="Chat with an ArkClaw EE space's Claw over enterprise SSO — no permanent keys.",
16
+ )
17
+
18
+
19
+ @app.command()
20
+ def login(
21
+ space: str = typer.Argument(
22
+ ...,
23
+ help="ArkClaw space URL, e.g. https://<space>.arkclaw-enterprise-bj.volceapi.com",
24
+ ),
25
+ role_trn: str | None = typer.Option(
26
+ None,
27
+ "--role-trn",
28
+ help="STS role TRN (admin-provided). Falls back to ARKCLAW_ROLE_TRN env "
29
+ "or the space's /.well-known/arkclaw-cli.",
30
+ ),
31
+ provider_trn: str | None = typer.Option(
32
+ None, "--provider-trn", help="OIDC provider TRN (optional; inferred from the token issuer)."
33
+ ),
34
+ region: str | None = typer.Option(
35
+ None, "--region", help="Space region (e.g. cn-beijing); derived from the URL if omitted."
36
+ ),
37
+ ) -> None:
38
+ """Log into an ArkClaw EE space by reusing your browser's SSO session.
39
+
40
+ You must already be logged into the space in Chrome. The id_token is read
41
+ from Chrome, exchanged for temporary credentials via STS, and cached — no
42
+ browser is opened, nothing is pasted, no permanent AK/SK is ever stored.
43
+ """
44
+ try:
45
+ core.do_login(
46
+ space, typer.echo, role_trn=role_trn, provider_trn=provider_trn, region=region
47
+ )
48
+ except (ValueError, RuntimeError) as exc:
49
+ typer.echo(f"✗ {exc}", err=True)
50
+ raise typer.Exit(1) from exc
51
+
52
+
53
+ @app.command()
54
+ def chat(
55
+ clawid: str | None = typer.Option(
56
+ None,
57
+ "--clawid",
58
+ help="Claw instance id (ci-...). Omit to use the claw you last had open in the browser.",
59
+ ),
60
+ ) -> None:
61
+ """Chat with a Claw in the logged-in space (Ctrl+C to exit)."""
62
+ try:
63
+ core.do_chat(clawid, typer.echo)
64
+ except (ValueError, RuntimeError) as exc:
65
+ typer.echo(f"✗ {exc}", err=True)
66
+ raise typer.Exit(1) from exc
67
+
68
+
69
+ def main() -> None:
70
+ app()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
ee_claw/core.py ADDED
@@ -0,0 +1,472 @@
1
+ """ArkClaw EE Claw access for the CLI.
2
+
3
+ Two user-facing commands sit on top of this module:
4
+
5
+ * ``arkclaw login --url <space>`` — resolve the space's IdP/region/role from the
6
+ URL, run the browser (PKCE loopback) login, exchange the resulting OIDC
7
+ id_token for **temporary** Volcengine credentials via ``AssumeRoleWithOIDC``
8
+ (no permanent AK/SK ever touches the user), and cache them.
9
+ * ``arkclaw chat --clawid <ci-...>`` — with the cached login, call
10
+ ``GetClawInstanceChatToken`` (temp creds) → open the OpenClaw WebSocket →
11
+ ``chat.send`` → stream the reply.
12
+
13
+ The whole chain (login → STS → GetClawInstanceChatToken → wss) was validated
14
+ end-to-end against a live ArkClaw EE claw. See the module-level constants for
15
+ the exact hosts/versions/protocol frames that were confirmed to work.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+ import uuid
28
+
29
+ STS_HOST = "sts.volcengineapi.com"
30
+ STS_VERSION = "2018-01-01"
31
+ ARKCLAW_API_VERSION = "2026-03-01"
32
+ ARKCLAW_SERVICE = "arkclaw"
33
+ _SESSION_KEY = "agent:main:main" # OpenClaw default session
34
+ _WS_CLIENT_ID = "openclaw-control-ui" # ONLY this client.id is accepted
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Space resolution (URL -> {issuer/client for login, region/role/provider for STS})
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def derive_region(url: str) -> str | None:
43
+ """Best-effort region from the space's gateway host
44
+ (``…apigateway-cn-shanghai…`` / ``…-bj…``). Overridable via --region."""
45
+ import re
46
+ host = (urllib.parse.urlparse(url if "//" in url else "https://" + url).hostname or "").lower()
47
+ m = re.search(r"(cn-[a-z]+|ap-[a-z]+-\d)", host)
48
+ if m:
49
+ return m.group(1)
50
+ if "-bj" in host:
51
+ return "cn-beijing"
52
+ if "-sh" in host:
53
+ return "cn-shanghai"
54
+ return None
55
+
56
+
57
+ def discover_cli_config(url: str) -> dict | None:
58
+ """Optional space self-description: ``GET <space>/.well-known/arkclaw-cli``
59
+ → ``{"region", "role_trn", "provider_trn"}``. Returns None if absent (404 /
60
+ not served) — the admin then supplies --role-trn instead. No hardcoding."""
61
+ base = (url if "//" in url else "https://" + url).rstrip("/")
62
+ try:
63
+ raw = urllib.request.urlopen(base + "/.well-known/arkclaw-cli", timeout=8).read()
64
+ d = json.loads(raw)
65
+ return d if isinstance(d, dict) else None
66
+ except Exception:
67
+ return None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Volcengine SigV4 (HMAC-SHA256), with optional STS session token
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _sign_headers(
76
+ method: str,
77
+ host: str,
78
+ query: dict[str, str],
79
+ body: bytes,
80
+ *,
81
+ ak: str,
82
+ sk: str,
83
+ service: str,
84
+ region: str,
85
+ session_token: str | None = None,
86
+ extra: dict[str, str] | None = None,
87
+ ) -> dict[str, str]:
88
+ now = datetime.datetime.now(datetime.timezone.utc)
89
+ xdate = now.strftime("%Y%m%dT%H%M%SZ")
90
+ datestamp = xdate[:8]
91
+ payload_hash = hashlib.sha256(body).hexdigest()
92
+ signed = {
93
+ "content-type": "application/json",
94
+ "host": host,
95
+ "x-content-sha256": payload_hash,
96
+ "x-date": xdate,
97
+ }
98
+ if session_token:
99
+ signed["x-security-token"] = session_token
100
+ signed_headers = ";".join(sorted(signed))
101
+ canon_headers = "".join(f"{k}:{signed[k]}\n" for k in sorted(signed))
102
+ canon_query = "&".join(
103
+ f"{urllib.parse.quote(k, safe='-_.~')}={urllib.parse.quote(str(v), safe='-_.~')}"
104
+ for k, v in sorted(query.items())
105
+ )
106
+ canon_req = f"{method}\n/\n{canon_query}\n{canon_headers}\n{signed_headers}\n{payload_hash}"
107
+ scope = f"{datestamp}/{region}/{service}/request"
108
+ sts = f"HMAC-SHA256\n{xdate}\n{scope}\n{hashlib.sha256(canon_req.encode()).hexdigest()}"
109
+
110
+ def _h(key: bytes, msg: str) -> bytes:
111
+ return hmac.new(key, msg.encode(), hashlib.sha256).digest()
112
+
113
+ k_signing = _h(_h(_h(_h(sk.encode(), datestamp), region), service), "request")
114
+ sig = hmac.new(k_signing, sts.encode(), hashlib.sha256).hexdigest()
115
+ out = {
116
+ "Content-Type": "application/json",
117
+ "Host": host,
118
+ "X-Date": xdate,
119
+ "X-Content-Sha256": payload_hash,
120
+ "Authorization": (
121
+ f"HMAC-SHA256 Credential={ak}/{scope}, "
122
+ f"SignedHeaders={signed_headers}, Signature={sig}"
123
+ ),
124
+ }
125
+ if session_token:
126
+ out["X-Security-Token"] = session_token
127
+ if extra:
128
+ out.update(extra)
129
+ return out
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # STS + GetClawInstanceChatToken
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def assume_role_with_oidc(
138
+ id_token: str, role_trn: str, provider_trn: str | None = None
139
+ ) -> dict[str, str]:
140
+ """Exchange a UserPool id_token for temporary creds. **Anonymous** (no AK/SK):
141
+ the OIDC token IS the credential. Token MUST go in the POST body (too long
142
+ for the query) and the host MUST be ``sts.volcengineapi.com``."""
143
+ params = {
144
+ "RoleTrn": role_trn,
145
+ "OIDCToken": id_token,
146
+ "RoleSessionName": "arkclaw-cli",
147
+ "DurationSeconds": "3600",
148
+ }
149
+ if provider_trn:
150
+ params["OIDCProviderTrn"] = provider_trn
151
+ body = urllib.parse.urlencode(params).encode()
152
+ url = f"https://{STS_HOST}/?Action=AssumeRoleWithOIDC&Version={STS_VERSION}"
153
+ req = urllib.request.Request(
154
+ url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}
155
+ )
156
+ try:
157
+ raw = urllib.request.urlopen(req, timeout=15).read()
158
+ except urllib.error.HTTPError as e:
159
+ raise RuntimeError(f"AssumeRoleWithOIDC rejected: {e.read().decode()[:300]}") from e
160
+ except urllib.error.URLError as e:
161
+ raise RuntimeError(f"cannot reach STS endpoint ({STS_HOST}): {e.reason}") from e
162
+ return json.loads(raw)["Result"]["Credentials"]
163
+
164
+
165
+ def get_chat_token(clawid: str, region: str, creds: dict[str, str]) -> tuple[str, str]:
166
+ """GetClawInstanceChatToken with temp creds → (ChatToken, Endpoint)."""
167
+ host = f"arkclaw.{region}.volcengineapi.com"
168
+ query = {"Action": "GetClawInstanceChatToken", "Version": ARKCLAW_API_VERSION}
169
+ body = json.dumps({"ClawInstanceId": clawid, "ProjectName": "default"}).encode()
170
+ headers = _sign_headers(
171
+ "POST", host, query, body,
172
+ ak=creds["AccessKeyId"], sk=creds["SecretAccessKey"],
173
+ service=ARKCLAW_SERVICE, region=region, session_token=creds.get("SessionToken"),
174
+ extra={"ServiceName": ARKCLAW_SERVICE, "Region": region},
175
+ )
176
+ url = f"https://{host}/?Action=GetClawInstanceChatToken&Version={ARKCLAW_API_VERSION}"
177
+ try:
178
+ raw = urllib.request.urlopen(
179
+ urllib.request.Request(url, data=body, headers=headers), timeout=30
180
+ ).read()
181
+ except urllib.error.HTTPError as e:
182
+ raise RuntimeError(f"GetClawInstanceChatToken failed: {e.read().decode()[:300]}") from e
183
+ res = json.loads(raw)["Result"]
184
+ return res["ChatToken"], res["Endpoint"]
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # OpenClaw WebSocket chat (protocol v3)
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ async def ws_chat(endpoint: str, chat_token: str, clawid: str, message: str, echo) -> str:
193
+ """connect (v3) → chat.send → stream assistant text until state=final."""
194
+ import websockets
195
+
196
+ url = f"wss://{endpoint}/?chatToken={urllib.parse.quote(chat_token)}&clawInstanceId={urllib.parse.quote(clawid)}"
197
+ final = ""
198
+ async with websockets.connect(url, max_size=None) as ws:
199
+ await ws.send(json.dumps({
200
+ "type": "req", "id": str(uuid.uuid4()), "method": "connect",
201
+ "params": {
202
+ "minProtocol": 3, "maxProtocol": 3,
203
+ "client": {"id": _WS_CLIENT_ID, "version": "arkclaw-cli", "platform": "cli", "mode": "webchat"},
204
+ "role": "operator", "scopes": ["operator.admin"], "caps": ["tool-events"], "locale": "zh-CN",
205
+ },
206
+ }))
207
+ import asyncio
208
+ sent = False
209
+ deadline = asyncio.get_event_loop().time() + 120
210
+ while asyncio.get_event_loop().time() < deadline:
211
+ try:
212
+ raw = await asyncio.wait_for(ws.recv(), 30)
213
+ except asyncio.TimeoutError:
214
+ break
215
+ msg = json.loads(raw)
216
+ if not sent and msg.get("type") == "res" and msg.get("ok"):
217
+ sent = True
218
+ await ws.send(json.dumps({
219
+ "type": "req", "id": str(uuid.uuid4()), "method": "chat.send",
220
+ "params": {"sessionKey": _SESSION_KEY, "message": message,
221
+ "deliver": False, "idempotencyKey": str(uuid.uuid4())},
222
+ }))
223
+ continue
224
+ if msg.get("event") == "agent":
225
+ data = (msg.get("payload") or {}).get("data") or {}
226
+ if data.get("delta"):
227
+ echo(data["delta"], nl=False)
228
+ if msg.get("event") == "chat":
229
+ p = msg.get("payload") or {}
230
+ if p.get("state") == "final":
231
+ content = ((p.get("message") or {}).get("content")) or ""
232
+ final = content if isinstance(content, str) else final
233
+ break
234
+ echo("")
235
+ return final
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Login store (~/.arkclaw/ee_login.json, 0600) + the two command entrypoints
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def _login_path():
244
+ import pathlib
245
+ d = pathlib.Path.home() / ".arkclaw"
246
+ d.mkdir(mode=0o700, exist_ok=True)
247
+ return d / "ee_login.json"
248
+
249
+
250
+ def save_login(data: dict) -> None:
251
+ import os
252
+ p = _login_path()
253
+ p.write_text(json.dumps(data))
254
+ os.chmod(p, 0o600)
255
+
256
+
257
+ def load_login() -> dict | None:
258
+ p = _login_path()
259
+ return json.loads(p.read_text()) if p.exists() else None
260
+
261
+
262
+ def _decode_claims(jwt: str) -> dict:
263
+ import base64
264
+ seg = jwt.split(".")[1]
265
+ return json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4)))
266
+
267
+
268
+ _JWT_RE = None
269
+
270
+
271
+ def read_chrome_token(space_host: str, key: str = "volcclaw_userpool_id_token") -> str | None:
272
+ """Read the freshest ``key`` for ``space_host`` directly from Chrome's
273
+ Local Storage (LevelDB), so the user doesn't copy/paste. Files are copied to
274
+ a temp dir first (Chrome holds a lock on the live DB). Best-effort: returns
275
+ None if not found (caller falls back to paste)."""
276
+ import pathlib
277
+ import re
278
+ import shutil
279
+ import tempfile
280
+
281
+ global _JWT_RE
282
+ if _JWT_RE is None:
283
+ _JWT_RE = re.compile(rb"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")
284
+ roots = [
285
+ pathlib.Path.home() / "Library/Application Support/Google/Chrome",
286
+ pathlib.Path.home() / "Library/Application Support/Google/Chrome Beta",
287
+ pathlib.Path.home() / ".config/google-chrome",
288
+ ]
289
+ host_b, key_b = space_host.encode(), key.encode()
290
+ best: tuple[str, int] | None = None
291
+ for root in roots:
292
+ if not root.exists():
293
+ continue
294
+ for ldb in root.glob("*/Local Storage/leveldb"):
295
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix="arkclaw-ls-"))
296
+ for f in ldb.glob("*"):
297
+ if f.suffix in (".ldb", ".log") or f.name.startswith("MANIFEST"):
298
+ try:
299
+ shutil.copy(f, tmp / f.name)
300
+ except OSError:
301
+ pass
302
+ for f in tmp.glob("*"):
303
+ try:
304
+ data = f.read_bytes()
305
+ except OSError:
306
+ continue
307
+ pos = 0
308
+ while True:
309
+ k = data.find(key_b, pos)
310
+ if k < 0:
311
+ break
312
+ pos = k + 1
313
+ if host_b not in data[max(0, k - 256):k]: # key is origin-prefixed
314
+ continue
315
+ m = _JWT_RE.search(data[k:k + 4096])
316
+ if not m:
317
+ continue
318
+ tok = m.group().decode()
319
+ try:
320
+ exp = int(_decode_claims(tok).get("exp", 0))
321
+ except Exception:
322
+ continue
323
+ if best is None or exp > best[1]:
324
+ best = (tok, exp)
325
+ shutil.rmtree(tmp, ignore_errors=True)
326
+ return best[0] if best else None
327
+
328
+
329
+ def read_chrome_recent_claw(space_host: str) -> str | None:
330
+ """Find the most-recently-visited claw in this space from Chrome history —
331
+ the chat page URL is ``<space_host>/clawSpace/<ci-...>/chat``. No server call,
332
+ no extra IAM permission (we read what the user just looked at, same disk-read
333
+ trick as the token). Returns the ``ci-...`` id or None."""
334
+ import pathlib
335
+ import re
336
+ import shutil
337
+ import sqlite3
338
+ import tempfile
339
+
340
+ pat = re.compile(r"/clawSpace/(ci-[a-z0-9]+)", re.I)
341
+ roots = [
342
+ pathlib.Path.home() / "Library/Application Support/Google/Chrome",
343
+ pathlib.Path.home() / "Library/Application Support/Google/Chrome Beta",
344
+ pathlib.Path.home() / ".config/google-chrome",
345
+ ]
346
+ best: tuple[int, str] | None = None # (last_visit_time, claw_id)
347
+ for root in roots:
348
+ if not root.exists():
349
+ continue
350
+ for hist in root.glob("*/History"):
351
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix="arkclaw-h-"))
352
+ db = tmp / "History"
353
+ try:
354
+ shutil.copy(hist, db)
355
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
356
+ rows = con.execute(
357
+ "SELECT url, last_visit_time FROM urls "
358
+ "WHERE url LIKE ? ORDER BY last_visit_time DESC LIMIT 50",
359
+ (f"%{space_host}/clawSpace/ci-%",),
360
+ ).fetchall()
361
+ con.close()
362
+ except Exception:
363
+ rows = []
364
+ finally:
365
+ shutil.rmtree(tmp, ignore_errors=True)
366
+ for url, t in rows:
367
+ m = pat.search(url or "")
368
+ if m and (best is None or (t or 0) > best[0]):
369
+ best = (t or 0, m.group(1))
370
+ return best[1] if best else None
371
+
372
+
373
+ def do_login(
374
+ url: str,
375
+ echo,
376
+ *,
377
+ role_trn: str | None = None,
378
+ provider_trn: str | None = None,
379
+ region: str | None = None,
380
+ ) -> None:
381
+ """`arkclaw login <space-url>`: read the id_token Chrome already holds for the
382
+ space (you must already be logged into it), exchange it for **temporary**
383
+ credentials via STS, and cache the session. No browser opened, no paste, no
384
+ permanent AK/SK ever. Nothing is hardcoded per space: the STS role comes from
385
+ ``--role-trn`` / ``ARKCLAW_ROLE_TRN`` / the space's ``/.well-known/arkclaw-cli``;
386
+ the region is derived from the URL (override with ``--region``)."""
387
+ base = (url if "//" in url else "https://" + url).rstrip("/")
388
+ host = urllib.parse.urlparse(base).hostname
389
+
390
+ # Config resolution (no hardcoding): explicit flag > env var > space
391
+ # self-description (/.well-known/arkclaw-cli) > derived. The admin sets the
392
+ # STS role once (env var, like AWS_ROLE_ARN); the production path is the space
393
+ # serving its own /.well-known/arkclaw-cli so the user types only the URL.
394
+ import os
395
+ disc = discover_cli_config(base) or {}
396
+ region = region or os.environ.get("ARKCLAW_REGION") or disc.get("region") or derive_region(base)
397
+ role_trn = role_trn or os.environ.get("ARKCLAW_ROLE_TRN") or disc.get("role_trn")
398
+ provider_trn = (
399
+ provider_trn or os.environ.get("ARKCLAW_PROVIDER_TRN") or disc.get("provider_trn")
400
+ )
401
+ if not region:
402
+ raise ValueError("ARKCLAW_E_REGION: 无法从空间地址推断区域,请加 --region <如 cn-beijing>。")
403
+ if not role_trn:
404
+ raise ValueError(
405
+ "ARKCLAW_E_UNCONFIGURED: 该空间未配置 CLI 访问。\n"
406
+ " 需要管理员提供 STS 角色 —— 加 --role-trn trn:iam::<account>:role/<name>,\n"
407
+ " 或由空间暴露 GET /.well-known/arkclaw-cli 供自动发现。"
408
+ )
409
+
410
+ # Read what Chrome already holds for this space. No browser, no paste.
411
+ id_token = read_chrome_token(host)
412
+ if not id_token:
413
+ raise ValueError(
414
+ f"ARKCLAW_E_NOLOGIN: 未在 Chrome 中找到 {host} 的登录态。"
415
+ " 请先在 Chrome 用企业 SSO 登录该空间(打开它的对话页)再运行本命令。"
416
+ )
417
+ if id_token.count(".") != 2:
418
+ raise ValueError("ARKCLAW_E_TOKEN: 从 Chrome 读到的不是有效 JWT id_token。")
419
+ echo("✓ 已从 Chrome 读取该空间登录态。")
420
+ claims = _decode_claims(id_token)
421
+ echo(f" ✓ 身份: {claims.get('email') or claims.get('name') or claims.get('sub')}"
422
+ f" (iss={(claims.get('iss') or '')[:48]}…)")
423
+ echo(" 校验登录中…")
424
+ try:
425
+ assume_role_with_oidc(id_token, role_trn, provider_trn)
426
+ except RuntimeError as e:
427
+ raise RuntimeError(
428
+ "ARKCLAW_E_STS: 用该登录换取临时凭据失败 —— 多半是管理员未为该空间身份池配置 STS "
429
+ "信任(OIDC provider / role),或角色无 arkclaw:GetClawInstanceChatToken 权限。\n"
430
+ f" 详情: {e}"
431
+ ) from e
432
+ claw = read_chrome_recent_claw(host) # what the user just had open; no server call
433
+ save_login({
434
+ "space": host, "url": base, "id_token": id_token,
435
+ "region": region, "role_trn": role_trn, "provider_trn": provider_trn,
436
+ "claw": claw,
437
+ })
438
+ echo(
439
+ f"✅ 已登录并保存(空间 {host} · 区域 {region}"
440
+ + (f" · 默认 claw {claw}" if claw else "")
441
+ + ")。现在: arkclaw chat"
442
+ + ("" if claw else " --clawid <ci-...>")
443
+ )
444
+
445
+
446
+ def do_chat(clawid: str | None, echo) -> None:
447
+ """`arkclaw chat [--clawid <ci-...>]`: cached login → STS temp creds →
448
+ GetClawInstanceChatToken → OpenClaw WebSocket REPL. Without --clawid, uses the
449
+ claw captured from the browser at login (last claw you had open)."""
450
+ import asyncio
451
+ data = load_login()
452
+ if not data:
453
+ raise ValueError("ARKCLAW_E_NOLOGIN: 尚未登录。先运行: arkclaw login <空间地址> --from-chrome")
454
+ clawid = clawid or data.get("claw")
455
+ if not clawid:
456
+ raise ValueError(
457
+ "ARKCLAW_E_NOCLAW: 未指定 claw,且登录时未从浏览器历史发现(可能没进过某个 claw 的对话页)。"
458
+ "请加 --clawid <ci-...>。"
459
+ )
460
+ creds = assume_role_with_oidc(data["id_token"], data["role_trn"], data.get("provider_trn"))
461
+ echo(f"ArkClaw chat · claw {clawid} · 空间 {data.get('space')} (Ctrl+C 退出)")
462
+ while True:
463
+ try:
464
+ msg = input("You> ").strip()
465
+ except (EOFError, KeyboardInterrupt):
466
+ echo("\nbye")
467
+ return
468
+ if not msg:
469
+ continue
470
+ chat_token, endpoint = get_chat_token(clawid, data["region"], creds) # one-time per ws
471
+ echo("Agent> ", nl=False)
472
+ asyncio.run(ws_chat(endpoint, chat_token, clawid, msg, echo))