arkclaw-webchat-cli 0.1.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.1.0/.gitignore +7 -0
- arkclaw_webchat_cli-0.1.0/PKG-INFO +87 -0
- arkclaw_webchat_cli-0.1.0/README.md +65 -0
- arkclaw_webchat_cli-0.1.0/pyproject.toml +47 -0
- arkclaw_webchat_cli-0.1.0/src/ee_claw/__init__.py +15 -0
- arkclaw_webchat_cli-0.1.0/src/ee_claw/cli.py +74 -0
- arkclaw_webchat_cli-0.1.0/src/ee_claw/core.py +472 -0
- arkclaw_webchat_cli-0.1.0/tests/test_smoke.py +41 -0
|
@@ -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,65 @@
|
|
|
1
|
+
# ee-claw
|
|
2
|
+
|
|
3
|
+
A tiny CLI to chat with a **Claw** in an **ArkClaw EE** space from your terminal —
|
|
4
|
+
authenticated by your existing **enterprise SSO** session, with **zero permanent
|
|
5
|
+
AK/SK** ever stored.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ee-claw
|
|
9
|
+
|
|
10
|
+
arkclaw login https://<space>.arkclaw-enterprise-bj.volceapi.com/
|
|
11
|
+
arkclaw chat
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That's it. `login` reuses the SSO session your browser already holds for the
|
|
15
|
+
space; `chat` talks to the Claw you last had open there.
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Chrome login (id_token) → STS AssumeRoleWithOIDC → temporary creds
|
|
21
|
+
→ GetClawInstanceChatToken → ChatToken
|
|
22
|
+
→ OpenClaw WebSocket → chat
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **`login <space-url>`** reads the `id_token` Chrome already holds for the space
|
|
26
|
+
(you must be logged in there), validates it by exchanging it for **temporary**
|
|
27
|
+
credentials via Volcengine STS, and caches the session in
|
|
28
|
+
`~/.arkclaw/ee_login.json` (mode `0600`). No browser is opened, nothing is
|
|
29
|
+
pasted, **no permanent AK/SK is ever written**.
|
|
30
|
+
- **`chat`** uses the cached login to mint a one-time `ChatToken`
|
|
31
|
+
(`GetClawInstanceChatToken`) and opens an OpenClaw WebSocket. Without
|
|
32
|
+
`--clawid` it uses the claw you most recently opened in the browser (read from
|
|
33
|
+
Chrome history); pass `--clawid ci-...` to target a specific one.
|
|
34
|
+
|
|
35
|
+
## Admin setup (once per space)
|
|
36
|
+
|
|
37
|
+
The CLI needs one piece of space-level configuration: the **STS role** whose
|
|
38
|
+
trust policy accepts the space's enterprise-SSO identity pool and whose
|
|
39
|
+
permission policy allows `arkclaw:GetClawInstanceChatToken`. Provide it via
|
|
40
|
+
(highest precedence first):
|
|
41
|
+
|
|
42
|
+
1. `--role-trn trn:iam::<account>:role/<name>`
|
|
43
|
+
2. `ARKCLAW_ROLE_TRN` environment variable
|
|
44
|
+
3. the space serving `GET <space-url>/.well-known/arkclaw-cli` →
|
|
45
|
+
`{"region": ..., "role_trn": ..., "provider_trn": ...}` (then the user types
|
|
46
|
+
only the URL)
|
|
47
|
+
|
|
48
|
+
Nothing is hardcoded per space. Region is derived from the URL (override with
|
|
49
|
+
`--region`); the OIDC provider is inferred from the token issuer (override with
|
|
50
|
+
`--provider-trn`). If no role can be resolved, `login` fails with
|
|
51
|
+
`ARKCLAW_E_UNCONFIGURED`.
|
|
52
|
+
|
|
53
|
+
## Security
|
|
54
|
+
|
|
55
|
+
The role is a least-privilege bridge: enterprise SSO identity → **1-hour**
|
|
56
|
+
temporary credentials that can do exactly **one** thing
|
|
57
|
+
(`GetClawInstanceChatToken`) and nothing else in the account. See the error
|
|
58
|
+
codes (`ARKCLAW_E_NOLOGIN`, `ARKCLAW_E_STS`, `ARKCLAW_E_UNCONFIGURED`, …) for
|
|
59
|
+
clear diagnostics.
|
|
60
|
+
|
|
61
|
+
## Scope
|
|
62
|
+
|
|
63
|
+
- Platform: **Chrome on macOS/Linux** (reads Chrome's Local Storage + history).
|
|
64
|
+
- This is the ArkClaw EE companion CLI; the general-purpose public SDK is
|
|
65
|
+
`arkclaw-sdk` (standard OIDC login + a2a chat) and lives separately.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arkclaw-webchat-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "ArkClaw Team" }]
|
|
12
|
+
keywords = ["arkclaw", "ee", "sso", "sts", "cli", "openclaw"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"typer>=0.12.0",
|
|
25
|
+
"websockets>=12.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=7.4",
|
|
31
|
+
"ruff>=0.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
arkclaw-webchat = "ee_claw.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/ee_claw"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
src = ["src", "tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "W", "F", "I", "UP", "B"]
|
|
47
|
+
ignore = ["E501"]
|
|
@@ -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__"]
|
|
@@ -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()
|
|
@@ -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))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Smoke tests — no network, no Chrome required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from ee_claw import core
|
|
8
|
+
from ee_claw.cli import app
|
|
9
|
+
|
|
10
|
+
runner = CliRunner()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_cli_help_shows_login_and_chat():
|
|
14
|
+
out = runner.invoke(app, ["--help"]).output
|
|
15
|
+
assert "login" in out
|
|
16
|
+
assert "chat" in out
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_login_takes_positional_space_url():
|
|
20
|
+
out = runner.invoke(app, ["login", "--help"]).output
|
|
21
|
+
assert "SPACE" in out.upper()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_chat_clawid_is_optional():
|
|
25
|
+
out = runner.invoke(app, ["chat", "--help"]).output
|
|
26
|
+
assert "--clawid" in out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_derive_region_from_url():
|
|
30
|
+
assert core.derive_region("https://x.arkclaw-enterprise-bj.volceapi.com") == "cn-beijing"
|
|
31
|
+
assert core.derive_region("https://y.apigateway-cn-shanghai.volceapi.com") == "cn-shanghai"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_chat_without_login_errors_cleanly(tmp_path, monkeypatch):
|
|
35
|
+
# Point the login store at an empty dir → load_login() returns None.
|
|
36
|
+
import pathlib
|
|
37
|
+
|
|
38
|
+
monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path))
|
|
39
|
+
result = runner.invoke(app, ["chat"])
|
|
40
|
+
assert result.exit_code == 1
|
|
41
|
+
assert "ARKCLAW_E_NOLOGIN" in result.output
|