arkclaw-webchat-cli 0.5.1__tar.gz → 0.6.0__tar.gz
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.
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/PKG-INFO +1 -1
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/pyproject.toml +1 -1
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/config.py +18 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/errors.py +7 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/flows.py +135 -49
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/identity.py +14 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/sts.py +2 -2
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/openclaw.py +43 -4
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/.gitignore +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/README.md +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/__init__.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/attachments.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/cli.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/control.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/core.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/doctor.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/oauth.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/output.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/policy.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/providers.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/secrets_store.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/__init__.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/a2a.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/base.py +0 -0
- {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/update.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arkclaw-webchat-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -126,6 +126,24 @@ def get_session(space: str, claw: str, session: str) -> dict[str, object] | None
|
|
|
126
126
|
return entry if isinstance(entry, dict) else None
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def forget_claw(space: str, claw: str) -> bool:
|
|
130
|
+
"""Drop ALL recorded sessions for a (space, claw) — used to self-heal the
|
|
131
|
+
`agents` list when a claw turns out to be inaccessible. Returns True if any
|
|
132
|
+
entry was removed."""
|
|
133
|
+
data = _load_sessions()
|
|
134
|
+
prefix = f"{space}|{claw}|"
|
|
135
|
+
doomed = [k for k in data if k.startswith(prefix)]
|
|
136
|
+
if not doomed:
|
|
137
|
+
return False
|
|
138
|
+
for k in doomed:
|
|
139
|
+
del data[k]
|
|
140
|
+
_dir()
|
|
141
|
+
fd = os.open(_sessions_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
142
|
+
with os.fdopen(fd, "w") as f:
|
|
143
|
+
f.write(json.dumps(data, ensure_ascii=False))
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
129
147
|
# --- Named-agent registry ------------------------------------------------------
|
|
130
148
|
# Maps a human-friendly agent name (the a2a agent-card name, an openclaw claw
|
|
131
149
|
# Name) → a non-secret connection snapshot, so `arkclaw chat <name>` works.
|
|
@@ -83,6 +83,13 @@ class StsError(AuthError):
|
|
|
83
83
|
code = "ARKCLAW_E_STS"
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
class ClawAccessDeniedError(StsError):
|
|
87
|
+
"""The user is not authorized for this specific claw (server per-user gate).
|
|
88
|
+
Distinct so callers can prune it from local history (self-healing)."""
|
|
89
|
+
|
|
90
|
+
code = "ARKCLAW_E_FORBIDDEN"
|
|
91
|
+
|
|
92
|
+
|
|
86
93
|
# --- Secret redaction -------------------------------------------------------
|
|
87
94
|
# Never let a full token reach the terminal, a log, or an error message.
|
|
88
95
|
#
|
|
@@ -11,6 +11,7 @@ and every error is a typed :class:`~ee_claw.errors.ArkclawError`.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
import dataclasses
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import pathlib
|
|
@@ -29,6 +30,7 @@ from ee_claw.attachments import collect_files
|
|
|
29
30
|
from ee_claw.config import SessionConfig
|
|
30
31
|
from ee_claw.errors import (
|
|
31
32
|
ArkclawError,
|
|
33
|
+
ClawAccessDeniedError,
|
|
32
34
|
ExpiredError,
|
|
33
35
|
NetworkError,
|
|
34
36
|
NoClawError,
|
|
@@ -51,7 +53,7 @@ from ee_claw.identity import (
|
|
|
51
53
|
from ee_claw.oauth import OAuthClient
|
|
52
54
|
from ee_claw.output import Emitter
|
|
53
55
|
from ee_claw.providers import ProviderContext, resolve_token_source
|
|
54
|
-
from ee_claw.sts import assume_role_with_oidc
|
|
56
|
+
from ee_claw.sts import assume_role_with_oidc, get_chat_token
|
|
55
57
|
from ee_claw.transport.a2a import A2ATransport, agent_card
|
|
56
58
|
from ee_claw.transport.base import Approver, Attachment, Transport, TurnEvent, TurnResult
|
|
57
59
|
from ee_claw.transport.openclaw import OpenClawTransport, session_key_for
|
|
@@ -114,6 +116,37 @@ def _user_identity(id_token: str | None) -> dict[str, str]:
|
|
|
114
116
|
return ident
|
|
115
117
|
|
|
116
118
|
|
|
119
|
+
def _probe_claw_access(
|
|
120
|
+
cfg: SessionConfig, id_token: str, claw_ids: list[str]
|
|
121
|
+
) -> tuple[set[str], set[str]]:
|
|
122
|
+
"""Which of ``claw_ids`` the current user can ACTUALLY chat — checked live by
|
|
123
|
+
minting a (throwaway) ChatToken for each, the only per-user check the CLI's
|
|
124
|
+
role can do. Returns (accessible, denied). A transient/non-permission error
|
|
125
|
+
keeps the claw (don't hide it on a network blip). Raises ArkclawError if
|
|
126
|
+
creds can't be obtained at all (caller falls back to the unchecked list)."""
|
|
127
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
128
|
+
|
|
129
|
+
creds = _openclaw_creds(cfg, id_token) # may raise (offline / expired / no creds)
|
|
130
|
+
identity = _user_identity(id_token)
|
|
131
|
+
region = str(cfg.region)
|
|
132
|
+
|
|
133
|
+
def check(cid: str) -> tuple[str, bool]:
|
|
134
|
+
try:
|
|
135
|
+
get_chat_token(cid, region, creds, identity=identity)
|
|
136
|
+
return cid, True
|
|
137
|
+
except ClawAccessDeniedError:
|
|
138
|
+
return cid, False
|
|
139
|
+
except ArkclawError:
|
|
140
|
+
return cid, True # transient/other — keep, don't silently drop
|
|
141
|
+
|
|
142
|
+
accessible: set[str] = set()
|
|
143
|
+
denied: set[str] = set()
|
|
144
|
+
with ThreadPoolExecutor(max_workers=min(8, max(1, len(claw_ids)))) as ex:
|
|
145
|
+
for cid, ok in ex.map(check, claw_ids):
|
|
146
|
+
(accessible if ok else denied).add(cid)
|
|
147
|
+
return accessible, denied
|
|
148
|
+
|
|
149
|
+
|
|
117
150
|
def _normalize(url: str) -> tuple[str, str]:
|
|
118
151
|
base = (url if "//" in url else "https://" + url).rstrip("/")
|
|
119
152
|
host = urllib.parse.urlparse(base).hostname or ""
|
|
@@ -129,55 +162,72 @@ def _is_userpool_address(host: str) -> bool:
|
|
|
129
162
|
|
|
130
163
|
|
|
131
164
|
def do_init(emitter: Emitter, address: str | None = None) -> dict[str, Any]:
|
|
132
|
-
"""``arkclaw init``: one-time
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
"""``arkclaw init``: one-time setup. The GOAL is **address-only** — when the
|
|
166
|
+
space publishes its CLI config (issuer/region/client_id/role…) via
|
|
167
|
+
``/.well-known/arkclaw-cli`` or GetAuthConfig, the user gives ONLY the
|
|
168
|
+
address and nothing is asked (works non-interactively too). Until then, it
|
|
169
|
+
prompts for the few bits discovery can't provide (the CLI's OAuth client,
|
|
170
|
+
the STS role)."""
|
|
171
|
+
interactive = not emitter.json and sys.stdin.isatty()
|
|
172
|
+
|
|
173
|
+
def need_tty() -> None:
|
|
174
|
+
if not interactive:
|
|
175
|
+
raise ValidationError(
|
|
176
|
+
"该空间尚未发布 CLI 配置,需要补充 client_id/role,而当前非交互终端。",
|
|
177
|
+
hint="在终端运行 `arkclaw init <地址>`;或脚本里用 env(ARKCLAW_CLIENT_ID/ROLE_TRN)+ flags。",
|
|
178
|
+
)
|
|
142
179
|
|
|
143
|
-
def ask(label: str, default: str | None = None, *, required: bool = False) -> str
|
|
180
|
+
def ask(label: str, default: str | None = None, *, required: bool = False) -> str:
|
|
144
181
|
suffix = f" [{default}]" if default else ""
|
|
145
182
|
while True:
|
|
146
183
|
ans = input(f" {label}{suffix}: ").strip() or (default or "")
|
|
147
184
|
if ans or not required:
|
|
148
|
-
return ans
|
|
185
|
+
return ans
|
|
149
186
|
emitter.line(" (必填)")
|
|
150
187
|
|
|
151
|
-
|
|
152
|
-
|
|
188
|
+
if not address:
|
|
189
|
+
need_tty()
|
|
190
|
+
emitter.line("arkclaw init —— 配置一次(能自动发现的不问你)\n")
|
|
191
|
+
address = ask("空间登录地址 (https://…)", required=True)
|
|
153
192
|
base, host = _normalize(str(address))
|
|
154
193
|
disc = discover_cli_config(base) or {}
|
|
155
194
|
issuer = disc.get("issuer") or (f"https://{host}" if _is_userpool_address(host) else None)
|
|
156
195
|
region = disc.get("region") or derive_region(base)
|
|
157
|
-
if
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"CLI
|
|
169
|
-
)
|
|
170
|
-
role_trn: object = None
|
|
171
|
-
endpoint: object = None
|
|
172
|
-
if transport == "openclaw":
|
|
173
|
-
role_trn = disc.get("role_trn") or ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
|
|
196
|
+
transport = "a2a" if str(disc.get("transport") or "").lower() == "a2a" else "openclaw"
|
|
197
|
+
client_id = disc.get("client_id")
|
|
198
|
+
role_trn = disc.get("role_trn")
|
|
199
|
+
provider_trn = disc.get("provider_trn")
|
|
200
|
+
endpoint = disc.get("endpoint")
|
|
201
|
+
clawid = disc.get("clawid")
|
|
202
|
+
|
|
203
|
+
# Fully discovered = the space published this CLI's client + target plane →
|
|
204
|
+
# nothing to ask (address-only). Otherwise prompt ONLY for what's missing.
|
|
205
|
+
complete = bool(client_id) and (bool(role_trn) if transport == "openclaw" else bool(endpoint))
|
|
206
|
+
if complete:
|
|
207
|
+
emitter.line("✓ 该空间已发布 CLI 配置 —— 全部自动解析,无需手动输入。")
|
|
174
208
|
else:
|
|
175
|
-
|
|
176
|
-
|
|
209
|
+
need_tty()
|
|
210
|
+
if issuer:
|
|
211
|
+
emitter.line(" ✓ 自动发现身份池 issuer")
|
|
212
|
+
if region:
|
|
213
|
+
emitter.line(f" ✓ 区域: {region}")
|
|
214
|
+
# "webchat" = the ChatToken/wss path (internal name "openclaw"); "a2a" unchanged.
|
|
215
|
+
t_in = ask("传输方式 (webchat/a2a)", default=("a2a" if transport == "a2a" else "webchat"))
|
|
216
|
+
transport = "a2a" if t_in.lower() == "a2a" else "openclaw"
|
|
217
|
+
if not client_id:
|
|
218
|
+
client_id = ask(
|
|
219
|
+
"CLI client_id(public OAuth 客户端,问管理员;平台发布发现后免此项)", required=True
|
|
220
|
+
)
|
|
221
|
+
if transport == "openclaw" and not role_trn:
|
|
222
|
+
role_trn = ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
|
|
223
|
+
elif transport == "a2a" and not endpoint:
|
|
224
|
+
endpoint = ask("A2A endpoint (https://…)", required=True)
|
|
225
|
+
if not clawid:
|
|
226
|
+
clawid = ask("默认 claw id (ci-…,可留空,登录后用 arkclaw agents 选)") or None
|
|
177
227
|
|
|
178
228
|
saved = {
|
|
179
229
|
"address": base, "issuer": issuer, "client_id": client_id, "transport": transport,
|
|
180
|
-
"region": region, "role_trn": role_trn, "provider_trn":
|
|
230
|
+
"region": region, "role_trn": role_trn, "provider_trn": provider_trn,
|
|
181
231
|
"endpoint": endpoint, "space_id": disc.get("space_id"), "clawid": clawid,
|
|
182
232
|
}
|
|
183
233
|
config_mod.save_defaults(saved)
|
|
@@ -706,6 +756,15 @@ def _save_session_state(cfg: SessionConfig, target: str, session: str | None, tr
|
|
|
706
756
|
config_mod.record_session(str(cfg.space), target, session or "main", extra)
|
|
707
757
|
|
|
708
758
|
|
|
759
|
+
def _forget_inaccessible(cfg: SessionConfig, claw: str) -> None:
|
|
760
|
+
"""A chat to ``claw`` was denied (not the user's, not shared). Drop it from
|
|
761
|
+
local history so `arkclaw agents` stops listing it, and clear it as the
|
|
762
|
+
default if it was the default (so a bare `arkclaw chat` won't keep failing)."""
|
|
763
|
+
config_mod.forget_claw(str(cfg.space), claw)
|
|
764
|
+
if cfg.claw == claw:
|
|
765
|
+
config_mod.save_config(dataclasses.replace(cfg, claw=None))
|
|
766
|
+
|
|
767
|
+
|
|
709
768
|
def _resolve_target(name: str) -> SessionConfig | None:
|
|
710
769
|
"""A chat target by name: the agent registry (card/claw names recorded by
|
|
711
770
|
``login``/``agents``) first, then profile names."""
|
|
@@ -782,13 +841,18 @@ def do_chat(
|
|
|
782
841
|
target, transport = _target_transport(
|
|
783
842
|
cfg, clawid, id_token, session=session, approver=approver
|
|
784
843
|
)
|
|
785
|
-
|
|
786
|
-
|
|
844
|
+
# NOTE: do NOT record the session here (before the turn) — a denied claw
|
|
845
|
+
# would pollute `arkclaw agents`. Recording happens only after a turn
|
|
846
|
+
# SUCCEEDS, via _save_session_state below.
|
|
787
847
|
if message is not None:
|
|
788
848
|
stdin_text = _stdin_context()
|
|
789
849
|
if stdin_text:
|
|
790
850
|
attachments.insert(0, Attachment(name="<stdin>", content=stdin_text.encode()))
|
|
791
|
-
|
|
851
|
+
try:
|
|
852
|
+
result = asyncio.run(_run_turn(transport, message, emitter, files=attachments))
|
|
853
|
+
except ClawAccessDeniedError:
|
|
854
|
+
_forget_inaccessible(cfg, target) # self-heal: drop it from `agents`
|
|
855
|
+
raise
|
|
792
856
|
emitter.line("")
|
|
793
857
|
if result.timed_out:
|
|
794
858
|
emitter.line("⚠ 回合超时结束,回复可能不完整。")
|
|
@@ -832,6 +896,8 @@ def do_chat(
|
|
|
832
896
|
except ArkclawError as e:
|
|
833
897
|
# One bad turn (server failure, network blip) must not kill the
|
|
834
898
|
# REPL — report it and keep the conversation open.
|
|
899
|
+
if isinstance(e, ClawAccessDeniedError):
|
|
900
|
+
_forget_inaccessible(cfg, target) # self-heal: drop it from `agents`
|
|
835
901
|
emitter.line(f"\n✗ {e.code}: {e.message}")
|
|
836
902
|
if e.hint:
|
|
837
903
|
emitter.line(f" {e.hint}")
|
|
@@ -964,12 +1030,11 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
|
|
|
964
1030
|
)
|
|
965
1031
|
elif cfg.transport == "openclaw":
|
|
966
1032
|
emitter.line(f"★ 当前登录(openclaw)· 空间 {cfg.space}")
|
|
967
|
-
#
|
|
968
|
-
#
|
|
969
|
-
#
|
|
970
|
-
#
|
|
971
|
-
#
|
|
972
|
-
# (record_session) and shows up here next time.
|
|
1033
|
+
# Candidates = the claws YOU have used from this machine (local history) +
|
|
1034
|
+
# your default — NOT a server enumeration (the space-wide list isn't
|
|
1035
|
+
# per-user scoped for the CLI's role). We then VERIFY access to each by
|
|
1036
|
+
# minting a throwaway ChatToken, so only claws you can actually chat are
|
|
1037
|
+
# shown; denied ones are pruned from history (and cleared as default).
|
|
973
1038
|
seen: dict[str, float] = {}
|
|
974
1039
|
for s in config_mod.list_sessions():
|
|
975
1040
|
cid = s.get("claw")
|
|
@@ -978,12 +1043,29 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
|
|
|
978
1043
|
seen[str(cid)] = max(seen.get(str(cid), 0.0), float(ts) if isinstance(ts, (int, float)) else 0.0)
|
|
979
1044
|
if cfg.claw and str(cfg.claw) not in seen:
|
|
980
1045
|
seen[str(cfg.claw)] = 0.0 # the default claw, even if not chatted yet
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1046
|
+
|
|
1047
|
+
claw_ids = sorted(seen, key=lambda c: seen[c], reverse=True)
|
|
1048
|
+
checked = False
|
|
1049
|
+
if claw_ids:
|
|
1050
|
+
if not emitter.json:
|
|
1051
|
+
emitter.line(" 核对访问权限中…")
|
|
1052
|
+
try:
|
|
1053
|
+
_, id_token = _load_session(cfg)
|
|
1054
|
+
accessible, denied = _probe_claw_access(cfg, id_token, claw_ids)
|
|
1055
|
+
checked = True
|
|
1056
|
+
for cid in denied:
|
|
1057
|
+
config_mod.forget_claw(str(cfg.space), cid) # stop listing it
|
|
1058
|
+
if cfg.claw == cid:
|
|
1059
|
+
config_mod.save_config(dataclasses.replace(cfg, claw=None))
|
|
1060
|
+
cfg = config_mod.load_config() or cfg
|
|
1061
|
+
current["default_claw"] = cfg.claw
|
|
1062
|
+
claw_ids = [c for c in claw_ids if c in accessible]
|
|
1063
|
+
except ArkclawError:
|
|
1064
|
+
checked = False # offline / expired / no creds → show unchecked
|
|
1065
|
+
|
|
1066
|
+
claws = [{"ClawInstanceId": cid, "last_used": seen[cid]} for cid in claw_ids]
|
|
985
1067
|
current["claws"] = claws
|
|
986
|
-
current["source"] = "local-history"
|
|
1068
|
+
current["source"] = "verified" if checked else "local-history-unchecked"
|
|
987
1069
|
if claws:
|
|
988
1070
|
emitter.table(
|
|
989
1071
|
["Claw(可 chat)", "上次使用", "默认"],
|
|
@@ -996,6 +1078,10 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
|
|
|
996
1078
|
for c in claws
|
|
997
1079
|
],
|
|
998
1080
|
)
|
|
1081
|
+
if not checked:
|
|
1082
|
+
emitter.line(" ⚠ 未能核对权限(离线/会话过期),以上为本地历史,可能含你无权访问的。")
|
|
1083
|
+
elif checked:
|
|
1084
|
+
emitter.line(" 你在本空间没有可访问的 claw(历史里的都已无权或不存在)。")
|
|
999
1085
|
else:
|
|
1000
1086
|
emitter.line(" 还没用过任何 claw。用 `arkclaw chat ci-xxxx` 开始,之后这里会记住它。")
|
|
1001
1087
|
|
|
@@ -118,6 +118,20 @@ def discover_cli_config(url: str) -> dict[str, object] | None:
|
|
|
118
118
|
if result.get(k):
|
|
119
119
|
out["client_id"] = result[k]
|
|
120
120
|
break
|
|
121
|
+
# The remaining CLI-plane fields (platform-added). Once the BFF surfaces
|
|
122
|
+
# these on GetAuthConfig, the user types ONLY the address — login/init
|
|
123
|
+
# resolve everything. (camelCase from the BFF → snake_case the CLI uses.)
|
|
124
|
+
for src, dst in (
|
|
125
|
+
("roleTrn", "role_trn"),
|
|
126
|
+
("providerTrn", "provider_trn"),
|
|
127
|
+
("region", "region"),
|
|
128
|
+
("spaceId", "space_id"),
|
|
129
|
+
("transport", "transport"),
|
|
130
|
+
("endpoint", "endpoint"),
|
|
131
|
+
("defaultClawId", "clawid"),
|
|
132
|
+
):
|
|
133
|
+
if result.get(src):
|
|
134
|
+
out[dst] = result[src]
|
|
121
135
|
return out or None
|
|
122
136
|
|
|
123
137
|
|
|
@@ -16,7 +16,7 @@ import urllib.error
|
|
|
16
16
|
import urllib.parse
|
|
17
17
|
import urllib.request
|
|
18
18
|
|
|
19
|
-
from ee_claw.errors import NetworkError, StsError, redact
|
|
19
|
+
from ee_claw.errors import ClawAccessDeniedError, NetworkError, StsError, redact
|
|
20
20
|
|
|
21
21
|
STS_HOST = "sts.volcengineapi.com"
|
|
22
22
|
STS_VERSION = "2018-01-01"
|
|
@@ -164,7 +164,7 @@ def get_chat_token(
|
|
|
164
164
|
err_body = e.read().decode(errors="replace")
|
|
165
165
|
code, message = _volc_error(err_body)
|
|
166
166
|
if code == "AccessDenied" or "AccessDenied" in err_body: # coruscant per-user gate
|
|
167
|
-
raise
|
|
167
|
+
raise ClawAccessDeniedError(
|
|
168
168
|
"权限不足:当前用户无权访问该 Claw 实例(非所有者且未获授权)。",
|
|
169
169
|
hint="运行 `arkclaw agents` 查看你有权访问的 Claw 实例,或使用 `--clawid` 指定其它实例。",
|
|
170
170
|
) from e
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
"""OpenClaw WebSocket transport (protocol v3).
|
|
1
|
+
"""OpenClaw WebSocket transport (protocol v3–v4, negotiated).
|
|
2
2
|
|
|
3
3
|
Refactored from the live-validated ``core.ws_chat`` (2026-06-05, real EE claw).
|
|
4
|
+
The connect frame negotiates ``minProtocol=3 .. maxProtocol=4`` so it works
|
|
5
|
+
against both older claws (v3) and OpenClaw ≥5.28 (v4); the server picks the
|
|
6
|
+
version. (The post-connect frame shapes below were calibrated on a live v3
|
|
7
|
+
claw; v4 is expected to be compatible for these — verify against a v4 claw.)
|
|
4
8
|
Confirmed protocol details, preserved exactly:
|
|
5
9
|
|
|
6
10
|
* The ChatToken from ``GetClawInstanceChatToken`` is **single-use** — a fresh
|
|
@@ -9,7 +13,7 @@ Confirmed protocol details, preserved exactly:
|
|
|
9
13
|
``connect.challenge`` event (no response required), then expects a
|
|
10
14
|
``connect`` request whose ``client.id`` MUST be ``openclaw-control-ui``
|
|
11
15
|
(server whitelist; anything else → INVALID_REQUEST), with
|
|
12
|
-
``role=operator`` / ``scopes=["operator.admin"]`` / protocol
|
|
16
|
+
``role=operator`` / ``scopes=["operator.admin"]`` / protocol negotiated v3–v4.
|
|
13
17
|
* ``chat.send`` (sessionKey ``agent:main:main``) → ``{ok:true, payload:{runId,
|
|
14
18
|
status:"started"}}``, then ``agent`` events stream assistant text
|
|
15
19
|
(``data.delta`` incremental) and the ``chat`` event with ``state=final``
|
|
@@ -31,6 +35,8 @@ from __future__ import annotations
|
|
|
31
35
|
|
|
32
36
|
import asyncio
|
|
33
37
|
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
34
40
|
import urllib.parse
|
|
35
41
|
import uuid
|
|
36
42
|
from collections.abc import Sequence
|
|
@@ -43,6 +49,11 @@ from ee_claw.transport.base import Approver, Attachment, OnEvent, TurnEvent, Tur
|
|
|
43
49
|
|
|
44
50
|
SESSION_KEY = "agent:main:main" # OpenClaw default session
|
|
45
51
|
WS_CLIENT_ID = "openclaw-control-ui" # ONLY this client.id is accepted
|
|
52
|
+
# ws protocol versions this client speaks; the server negotiates the highest
|
|
53
|
+
# mutually-supported one (v3 for older claws, v4 for OpenClaw ≥ 5.28). Pinning a
|
|
54
|
+
# single version makes a newer claw reject the connect with PROTOCOL_MISMATCH.
|
|
55
|
+
MIN_PROTOCOL = 3
|
|
56
|
+
MAX_PROTOCOL = 4
|
|
46
57
|
|
|
47
58
|
|
|
48
59
|
def session_key_for(name: str | None) -> str:
|
|
@@ -61,14 +72,40 @@ class _Ws(Protocol):
|
|
|
61
72
|
async def recv(self) -> str | bytes: ...
|
|
62
73
|
|
|
63
74
|
|
|
75
|
+
class _DebugWs:
|
|
76
|
+
"""Frame tracer. With ``ARKCLAW_DEBUG_WS`` set, dump every ws frame (sent
|
|
77
|
+
and received) to stderr — redacted (no tokens) and length-capped — for
|
|
78
|
+
diagnosing protocol changes (e.g. OpenClaw v4) against a live claw."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, ws: Any) -> None:
|
|
81
|
+
self._ws = ws
|
|
82
|
+
|
|
83
|
+
async def send(self, data: str) -> None:
|
|
84
|
+
sys.stderr.write(f"[ws →] {redact(data)[:2000]}\n")
|
|
85
|
+
sys.stderr.flush()
|
|
86
|
+
await self._ws.send(data)
|
|
87
|
+
|
|
88
|
+
async def recv(self) -> str | bytes:
|
|
89
|
+
data = await self._ws.recv()
|
|
90
|
+
text = data if isinstance(data, str) else bytes(data).decode("utf-8", "replace")
|
|
91
|
+
sys.stderr.write(f"[ws ←] {redact(text)[:2000]}\n")
|
|
92
|
+
sys.stderr.flush()
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wrap_debug(ws: Any) -> Any:
|
|
97
|
+
"""Wrap ``ws`` in a frame tracer when ARKCLAW_DEBUG_WS is set; else pass through."""
|
|
98
|
+
return _DebugWs(ws) if os.environ.get("ARKCLAW_DEBUG_WS") else ws
|
|
99
|
+
|
|
100
|
+
|
|
64
101
|
def connect_frame() -> dict[str, Any]:
|
|
65
102
|
return {
|
|
66
103
|
"type": "req",
|
|
67
104
|
"id": str(uuid.uuid4()),
|
|
68
105
|
"method": "connect",
|
|
69
106
|
"params": {
|
|
70
|
-
"minProtocol":
|
|
71
|
-
"maxProtocol":
|
|
107
|
+
"minProtocol": MIN_PROTOCOL,
|
|
108
|
+
"maxProtocol": MAX_PROTOCOL,
|
|
72
109
|
"client": {
|
|
73
110
|
"id": WS_CLIENT_ID,
|
|
74
111
|
"version": "arkclaw-cli",
|
|
@@ -369,6 +406,7 @@ class OpenClawTransport:
|
|
|
369
406
|
connect = websockets.connect
|
|
370
407
|
try:
|
|
371
408
|
async with connect(url, max_size=None) as ws:
|
|
409
|
+
ws = _wrap_debug(ws)
|
|
372
410
|
self._active_ws = ws
|
|
373
411
|
try:
|
|
374
412
|
return await drive_chat(
|
|
@@ -428,6 +466,7 @@ class OpenClawTransport:
|
|
|
428
466
|
connect = websockets.connect
|
|
429
467
|
try:
|
|
430
468
|
async with connect(url, max_size=None) as ws:
|
|
469
|
+
ws = _wrap_debug(ws)
|
|
431
470
|
await ws.send(json.dumps(connect_frame()))
|
|
432
471
|
loop = asyncio.get_running_loop()
|
|
433
472
|
deadline = loop.time() + self.turn_timeout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|