aster-cli 0.1.2__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.
- aster_cli/__init__.py +1 -0
- aster_cli/access.py +270 -0
- aster_cli/aster_service.py +300 -0
- aster_cli/codegen.py +828 -0
- aster_cli/codegen_typescript.py +819 -0
- aster_cli/contract.py +1112 -0
- aster_cli/credentials.py +87 -0
- aster_cli/enroll.py +315 -0
- aster_cli/handle_validation.py +53 -0
- aster_cli/identity.py +194 -0
- aster_cli/init.py +104 -0
- aster_cli/join.py +442 -0
- aster_cli/keygen.py +203 -0
- aster_cli/main.py +15 -0
- aster_cli/mcp/__init__.py +13 -0
- aster_cli/mcp/schema.py +205 -0
- aster_cli/mcp/security.py +108 -0
- aster_cli/mcp/server.py +407 -0
- aster_cli/profile.py +334 -0
- aster_cli/publish.py +598 -0
- aster_cli/shell/__init__.py +17 -0
- aster_cli/shell/app.py +2390 -0
- aster_cli/shell/commands.py +1624 -0
- aster_cli/shell/completer.py +156 -0
- aster_cli/shell/display.py +405 -0
- aster_cli/shell/guide.py +230 -0
- aster_cli/shell/hooks.py +255 -0
- aster_cli/shell/invoker.py +430 -0
- aster_cli/shell/plugin.py +185 -0
- aster_cli/shell/vfs.py +438 -0
- aster_cli/signer.py +150 -0
- aster_cli/templates/llm/python.md +578 -0
- aster_cli/trust.py +244 -0
- aster_cli-0.1.2.dist-info/METADATA +10 -0
- aster_cli-0.1.2.dist-info/RECORD +38 -0
- aster_cli-0.1.2.dist-info/WHEEL +5 -0
- aster_cli-0.1.2.dist-info/entry_points.txt +2 -0
- aster_cli-0.1.2.dist-info/top_level.txt +1 -0
aster_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""aster_cli -- Aster RPC framework command-line tools."""
|
aster_cli/access.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Access-control CLI commands for the Day 0 @aster service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from aster_cli.aster_service import (
|
|
10
|
+
build_signed_envelope,
|
|
11
|
+
generate_nonce,
|
|
12
|
+
now_epoch_seconds,
|
|
13
|
+
open_aster_service,
|
|
14
|
+
parse_duration_seconds,
|
|
15
|
+
)
|
|
16
|
+
from aster_cli.profile import get_active_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_verified_handle() -> str:
|
|
20
|
+
_profile_name, profile, _config = get_active_profile()
|
|
21
|
+
handle = str(profile.get("handle", "")).strip()
|
|
22
|
+
if profile.get("handle_status") != "verified" or not handle:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"access commands require a verified handle. Finish `aster join` / `aster verify` first."
|
|
25
|
+
)
|
|
26
|
+
return handle
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _signed_payload(action: str, **fields: object) -> dict[str, object]:
|
|
30
|
+
payload: dict[str, object] = {
|
|
31
|
+
"action": action,
|
|
32
|
+
**fields,
|
|
33
|
+
"timestamp": now_epoch_seconds(),
|
|
34
|
+
"nonce": generate_nonce(),
|
|
35
|
+
}
|
|
36
|
+
return payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_access_grant(args: argparse.Namespace) -> int:
|
|
40
|
+
try:
|
|
41
|
+
return asyncio.run(_grant_remote(args, handle=_require_verified_handle()))
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
print(f"Error: {exc}")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _grant_remote(args: argparse.Namespace, *, handle: str) -> int:
|
|
48
|
+
runtime = await open_aster_service(getattr(args, "aster", None))
|
|
49
|
+
try:
|
|
50
|
+
access_client = await runtime.access_client()
|
|
51
|
+
payload: dict[str, object] = _signed_payload(
|
|
52
|
+
"grant_access",
|
|
53
|
+
handle=handle,
|
|
54
|
+
service_name=args.service,
|
|
55
|
+
consumer_handle=args.consumer,
|
|
56
|
+
role=args.role,
|
|
57
|
+
scope=args.scope,
|
|
58
|
+
scope_node_id=args.scope_node_id,
|
|
59
|
+
)
|
|
60
|
+
envelope = build_signed_envelope(payload, root_key_file=getattr(args, "root_key", None))
|
|
61
|
+
result = await access_client.grant_access(runtime.signed_request(envelope))
|
|
62
|
+
finally:
|
|
63
|
+
await runtime.close()
|
|
64
|
+
|
|
65
|
+
print(
|
|
66
|
+
f"Granted {getattr(result, 'role', args.role)} access to "
|
|
67
|
+
f"@{getattr(result, 'consumer_handle', args.consumer)} for {args.service} "
|
|
68
|
+
f"({getattr(result, 'scope', args.scope)})."
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_access_revoke(args: argparse.Namespace) -> int:
|
|
74
|
+
try:
|
|
75
|
+
return asyncio.run(_revoke_remote(args, handle=_require_verified_handle()))
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
print(f"Error: {exc}")
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _revoke_remote(args: argparse.Namespace, *, handle: str) -> int:
|
|
82
|
+
runtime = await open_aster_service(getattr(args, "aster", None))
|
|
83
|
+
try:
|
|
84
|
+
access_client = await runtime.access_client()
|
|
85
|
+
payload = _signed_payload(
|
|
86
|
+
"revoke_access",
|
|
87
|
+
handle=handle,
|
|
88
|
+
service_name=args.service,
|
|
89
|
+
consumer_handle=args.consumer,
|
|
90
|
+
)
|
|
91
|
+
envelope = build_signed_envelope(payload, root_key_file=getattr(args, "root_key", None))
|
|
92
|
+
result = await access_client.revoke_access(runtime.signed_request(envelope))
|
|
93
|
+
finally:
|
|
94
|
+
await runtime.close()
|
|
95
|
+
|
|
96
|
+
if not getattr(result, "revoked", False):
|
|
97
|
+
print(f"No access grant found for @{args.consumer} on {args.service}.")
|
|
98
|
+
return 1
|
|
99
|
+
print(f"Revoked access for @{getattr(result, 'consumer_handle', args.consumer)} on {args.service}.")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_access_list(args: argparse.Namespace) -> int:
|
|
104
|
+
try:
|
|
105
|
+
return asyncio.run(_list_remote(args, handle=_require_verified_handle()))
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
print(f"Error: {exc}")
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _list_remote(args: argparse.Namespace, *, handle: str) -> int:
|
|
112
|
+
runtime = await open_aster_service(getattr(args, "aster", None))
|
|
113
|
+
try:
|
|
114
|
+
access_client = await runtime.access_client()
|
|
115
|
+
payload = _signed_payload(
|
|
116
|
+
"list_access",
|
|
117
|
+
handle=handle,
|
|
118
|
+
service_name=args.service,
|
|
119
|
+
)
|
|
120
|
+
envelope = build_signed_envelope(payload, root_key_file=getattr(args, "root_key", None))
|
|
121
|
+
result = await access_client.list_access(runtime.signed_request(envelope))
|
|
122
|
+
finally:
|
|
123
|
+
await runtime.close()
|
|
124
|
+
|
|
125
|
+
grants = [
|
|
126
|
+
{
|
|
127
|
+
"consumer_handle": getattr(entry, "consumer_handle", ""),
|
|
128
|
+
"role": getattr(entry, "role", ""),
|
|
129
|
+
"scope": getattr(entry, "scope", ""),
|
|
130
|
+
"granted_at": getattr(entry, "granted_at", ""),
|
|
131
|
+
}
|
|
132
|
+
for entry in getattr(result, "grants", [])
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
if getattr(args, "raw_json", False):
|
|
136
|
+
print(json.dumps({"service_name": args.service, "grants": grants}, indent=2))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
print(f"{args.service}: {len(grants)} grant(s)")
|
|
140
|
+
for grant in grants:
|
|
141
|
+
print(
|
|
142
|
+
f" @{grant['consumer_handle']} "
|
|
143
|
+
f"{grant['role'] or 'consumer'} "
|
|
144
|
+
f"{grant['scope'] or 'handle'} "
|
|
145
|
+
f"{grant['granted_at'] or ''}".rstrip()
|
|
146
|
+
)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _delegation_roles(args: argparse.Namespace) -> list[str]:
|
|
151
|
+
if getattr(args, "role", None):
|
|
152
|
+
return sorted(set(args.role))
|
|
153
|
+
return ["consumer"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_access_delegation(args: argparse.Namespace) -> int:
|
|
157
|
+
try:
|
|
158
|
+
return asyncio.run(_delegation_remote(args, handle=_require_verified_handle()))
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
print(f"Error: {exc}")
|
|
161
|
+
return 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _delegation_remote(args: argparse.Namespace, *, handle: str) -> int:
|
|
165
|
+
runtime = await open_aster_service(getattr(args, "aster", None))
|
|
166
|
+
try:
|
|
167
|
+
access_client = await runtime.access_client()
|
|
168
|
+
payload = _signed_payload(
|
|
169
|
+
"update_delegation",
|
|
170
|
+
handle=handle,
|
|
171
|
+
service_name=args.service,
|
|
172
|
+
delegation={
|
|
173
|
+
"authority": "consumer",
|
|
174
|
+
"mode": "closed" if args.closed else "open",
|
|
175
|
+
"token_ttl": parse_duration_seconds(args.token_ttl, default=300),
|
|
176
|
+
"rate_limit": args.rate_limit,
|
|
177
|
+
"roles": _delegation_roles(args),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
envelope = build_signed_envelope(payload, root_key_file=getattr(args, "root_key", None))
|
|
181
|
+
result = await access_client.update_delegation(runtime.signed_request(envelope))
|
|
182
|
+
finally:
|
|
183
|
+
await runtime.close()
|
|
184
|
+
|
|
185
|
+
delegation = getattr(result, "delegation", None)
|
|
186
|
+
mode = getattr(delegation, "mode", "closed" if args.closed else "open")
|
|
187
|
+
print(f"Updated @{handle}/{args.service} delegation to {mode}.")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_access_public_private(args: argparse.Namespace) -> int:
|
|
192
|
+
from aster_cli.publish import cmd_set_visibility
|
|
193
|
+
|
|
194
|
+
visibility = "public" if args.access_command == "public" else "private"
|
|
195
|
+
return cmd_set_visibility(
|
|
196
|
+
argparse.Namespace(
|
|
197
|
+
command="visibility",
|
|
198
|
+
service=args.service,
|
|
199
|
+
visibility=visibility,
|
|
200
|
+
aster=getattr(args, "aster", None),
|
|
201
|
+
root_key=getattr(args, "root_key", None),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def register_access_subparser(subparsers: argparse._SubParsersAction) -> None:
|
|
207
|
+
access_parser = subparsers.add_parser("access", help="Manage Day 0 @aster service access grants")
|
|
208
|
+
access_subparsers = access_parser.add_subparsers(dest="access_command")
|
|
209
|
+
|
|
210
|
+
grant_parser = access_subparsers.add_parser("grant", help="Grant a consumer access to a service")
|
|
211
|
+
grant_parser.add_argument("service", help="Published service name")
|
|
212
|
+
grant_parser.add_argument("consumer", help="Consumer handle to grant")
|
|
213
|
+
grant_parser.add_argument("--role", default="consumer", help="Delegated role (default: consumer)")
|
|
214
|
+
grant_parser.add_argument(
|
|
215
|
+
"--scope",
|
|
216
|
+
choices=["handle", "node"],
|
|
217
|
+
default="handle",
|
|
218
|
+
help="Grant scope (default: handle)",
|
|
219
|
+
)
|
|
220
|
+
grant_parser.add_argument("--scope-node-id", default=None, help="Required when --scope node")
|
|
221
|
+
grant_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
222
|
+
grant_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
223
|
+
|
|
224
|
+
revoke_parser = access_subparsers.add_parser("revoke", help="Revoke a consumer's service access")
|
|
225
|
+
revoke_parser.add_argument("service", help="Published service name")
|
|
226
|
+
revoke_parser.add_argument("consumer", help="Consumer handle to revoke")
|
|
227
|
+
revoke_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
228
|
+
revoke_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
229
|
+
|
|
230
|
+
list_parser = access_subparsers.add_parser("list", help="List access grants for a published service")
|
|
231
|
+
list_parser.add_argument("service", help="Published service name")
|
|
232
|
+
list_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
233
|
+
list_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
234
|
+
list_parser.add_argument("--json", action="store_true", dest="raw_json", help="Output raw JSON")
|
|
235
|
+
|
|
236
|
+
delegation_parser = access_subparsers.add_parser("delegation", help="Update delegated access mode for a service")
|
|
237
|
+
delegation_parser.add_argument("service", help="Published service name")
|
|
238
|
+
delegation_mode = delegation_parser.add_mutually_exclusive_group()
|
|
239
|
+
delegation_mode.add_argument("--open", action="store_true", help="Allow open enrollment")
|
|
240
|
+
delegation_mode.add_argument("--closed", action="store_true", help="Require explicit grants")
|
|
241
|
+
delegation_parser.add_argument("--token-ttl", default="5m", help="Delegated token TTL (default: 5m)")
|
|
242
|
+
delegation_parser.add_argument("--rate-limit", default=None, help='Delegated issuance rate limit like "1/60m"')
|
|
243
|
+
delegation_parser.add_argument("--role", action="append", default=[], help="Delegated role (repeatable)")
|
|
244
|
+
delegation_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
245
|
+
delegation_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
246
|
+
|
|
247
|
+
public_parser = access_subparsers.add_parser("public", help="Make a published service discoverable")
|
|
248
|
+
public_parser.add_argument("service", help="Published service name")
|
|
249
|
+
public_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
250
|
+
public_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
251
|
+
|
|
252
|
+
private_parser = access_subparsers.add_parser("private", help="Hide a published service from discovery")
|
|
253
|
+
private_parser.add_argument("service", help="Published service name")
|
|
254
|
+
private_parser.add_argument("--aster", default=None, help="Override @aster service address")
|
|
255
|
+
private_parser.add_argument("--root-key", default=None, help="Path to root key JSON backup")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def run_access_command(args: argparse.Namespace) -> int:
|
|
259
|
+
if args.access_command == "grant":
|
|
260
|
+
return cmd_access_grant(args)
|
|
261
|
+
if args.access_command == "revoke":
|
|
262
|
+
return cmd_access_revoke(args)
|
|
263
|
+
if args.access_command == "list":
|
|
264
|
+
return cmd_access_list(args)
|
|
265
|
+
if args.access_command == "delegation":
|
|
266
|
+
return cmd_access_delegation(args)
|
|
267
|
+
if args.access_command in {"public", "private"}:
|
|
268
|
+
return cmd_access_public_private(args)
|
|
269
|
+
print("Usage: aster access [grant|revoke|list] ...")
|
|
270
|
+
return 1
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Helpers for talking to the Day 0 @aster service from the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from aster.trust.signing import load_private_key
|
|
19
|
+
from aster_cli.codegen import generate_python_clients
|
|
20
|
+
from aster_cli.credentials import get_root_privkey
|
|
21
|
+
from aster_cli.profile import get_active_profile, get_aster_service_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def now_epoch_seconds() -> int:
|
|
25
|
+
return int(time.time())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_nonce() -> str:
|
|
29
|
+
return secrets.token_hex(16)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_payload_json(payload: dict[str, Any]) -> str:
|
|
33
|
+
"""Serialize payloads in a stable form before signing."""
|
|
34
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_root_private_key_hex(
|
|
38
|
+
*,
|
|
39
|
+
root_key_file: str | None = None,
|
|
40
|
+
profile_name: str | None = None,
|
|
41
|
+
) -> tuple[str, str]:
|
|
42
|
+
"""Return (profile_name, private_key_hex) for the active profile."""
|
|
43
|
+
active_name, _profile, _config = get_active_profile()
|
|
44
|
+
name = profile_name or active_name
|
|
45
|
+
|
|
46
|
+
priv_hex = get_root_privkey(name)
|
|
47
|
+
if priv_hex:
|
|
48
|
+
return name, priv_hex
|
|
49
|
+
|
|
50
|
+
key_path = Path(os.path.expanduser(root_key_file or "~/.aster/root.key"))
|
|
51
|
+
if key_path.exists():
|
|
52
|
+
data = json.loads(key_path.read_text())
|
|
53
|
+
priv_hex = str(data.get("private_key", "")).strip()
|
|
54
|
+
if priv_hex:
|
|
55
|
+
return name, priv_hex
|
|
56
|
+
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"no root private key found for profile '{name}'. "
|
|
59
|
+
"Run `aster keygen root` first, or pass --root-key."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_aster_service_address(explicit: str | None = None) -> str:
|
|
64
|
+
"""Resolve the @aster service address from CLI arg, env, or config."""
|
|
65
|
+
if explicit:
|
|
66
|
+
return explicit
|
|
67
|
+
|
|
68
|
+
env_addr = os.environ.get("ASTER_SERVICE_ADDR", "").strip()
|
|
69
|
+
if env_addr:
|
|
70
|
+
return env_addr
|
|
71
|
+
|
|
72
|
+
_name, _profile, config = get_active_profile()
|
|
73
|
+
service_cfg = get_aster_service_config(config)
|
|
74
|
+
if not service_cfg.get("enabled", True):
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
"@aster service access is disabled in config. "
|
|
77
|
+
"Pass --aster to override, or re-enable `[aster_service].enabled`."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
addr = str(service_cfg.get("node_id", "")).strip()
|
|
81
|
+
if addr:
|
|
82
|
+
return addr
|
|
83
|
+
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"no @aster service address configured. "
|
|
86
|
+
"Set `[aster_service].node_id`, export `ASTER_SERVICE_ADDR`, or pass --aster."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_duration_seconds(value: str | int | None, *, default: int) -> int:
|
|
91
|
+
"""Parse a simple duration like 300, 5m, 1h, or 1d."""
|
|
92
|
+
if value is None:
|
|
93
|
+
return default
|
|
94
|
+
if isinstance(value, int):
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
raw = str(value).strip().lower()
|
|
98
|
+
if not raw:
|
|
99
|
+
return default
|
|
100
|
+
if raw.isdigit():
|
|
101
|
+
return int(raw)
|
|
102
|
+
|
|
103
|
+
unit = raw[-1]
|
|
104
|
+
number = raw[:-1]
|
|
105
|
+
if not number.isdigit():
|
|
106
|
+
raise ValueError(f"invalid duration: {value!r}")
|
|
107
|
+
count = int(number)
|
|
108
|
+
if unit == "s":
|
|
109
|
+
return count
|
|
110
|
+
if unit == "m":
|
|
111
|
+
return count * 60
|
|
112
|
+
if unit == "h":
|
|
113
|
+
return count * 3600
|
|
114
|
+
if unit == "d":
|
|
115
|
+
return count * 86400
|
|
116
|
+
raise ValueError(f"invalid duration: {value!r}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_local_endpoint_id(identity_path: str | None = None) -> str | None:
|
|
120
|
+
"""Load the current node endpoint_id from .aster-identity if present."""
|
|
121
|
+
path = Path(identity_path or ".aster-identity")
|
|
122
|
+
if not path.exists():
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
from aster_cli.identity import load_identity
|
|
126
|
+
|
|
127
|
+
data = load_identity(path)
|
|
128
|
+
node = data.get("node", {})
|
|
129
|
+
endpoint_id = str(node.get("endpoint_id", "")).strip()
|
|
130
|
+
return endpoint_id or None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class SignedEnvelope:
|
|
135
|
+
payload: dict[str, Any]
|
|
136
|
+
payload_json: str
|
|
137
|
+
signer_pubkey: str
|
|
138
|
+
signature: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_signed_envelope(
|
|
142
|
+
payload: dict[str, Any],
|
|
143
|
+
*,
|
|
144
|
+
root_key_file: str | None = None,
|
|
145
|
+
profile_name: str | None = None,
|
|
146
|
+
signer_pubkey: str | None = None,
|
|
147
|
+
) -> SignedEnvelope:
|
|
148
|
+
"""Sign a payload for the Day 0 @aster SignedRequest wrapper."""
|
|
149
|
+
active_name, profile, _config = get_active_profile()
|
|
150
|
+
name, priv_hex = load_root_private_key_hex(
|
|
151
|
+
root_key_file=root_key_file,
|
|
152
|
+
profile_name=profile_name or active_name,
|
|
153
|
+
)
|
|
154
|
+
pub_hex = signer_pubkey or str(profile.get("root_pubkey", "")).strip()
|
|
155
|
+
if not pub_hex:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"profile '{name}' has no root public key configured. "
|
|
158
|
+
"Run `aster keygen root` first."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
payload_json = canonical_payload_json(payload)
|
|
162
|
+
signature = load_private_key(bytes.fromhex(priv_hex)).sign(payload_json.encode("utf-8")).hex()
|
|
163
|
+
return SignedEnvelope(
|
|
164
|
+
payload=payload,
|
|
165
|
+
payload_json=payload_json,
|
|
166
|
+
signer_pubkey=pub_hex,
|
|
167
|
+
signature=signature,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AsterServiceRuntime:
|
|
172
|
+
"""Runtime-generated typed clients for the current @aster node."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, address: str):
|
|
175
|
+
self.address = address
|
|
176
|
+
self._peer: PeerConnection | None = None
|
|
177
|
+
self._package_name: str | None = None
|
|
178
|
+
self._temp_dir: str | None = None
|
|
179
|
+
self._loaded: bool = False
|
|
180
|
+
self._types_signed_request: type[Any] | None = None
|
|
181
|
+
self._profile_client_cls: type[Any] | None = None
|
|
182
|
+
self._publication_client_cls: type[Any] | None = None
|
|
183
|
+
self._access_client_cls: type[Any] | None = None
|
|
184
|
+
self._clients: dict[str, Any] = {}
|
|
185
|
+
|
|
186
|
+
async def connect(self) -> None:
|
|
187
|
+
from aster_cli.shell.app import PeerConnection
|
|
188
|
+
|
|
189
|
+
peer = PeerConnection(self.address)
|
|
190
|
+
await peer.connect()
|
|
191
|
+
manifests: dict[str, dict[str, Any]] = {}
|
|
192
|
+
last_error: Exception | None = None
|
|
193
|
+
for _attempt in range(4):
|
|
194
|
+
try:
|
|
195
|
+
await peer._fetch_manifests()
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
last_error = exc
|
|
198
|
+
manifests = peer.get_manifests()
|
|
199
|
+
if manifests:
|
|
200
|
+
break
|
|
201
|
+
await asyncio.sleep(0.5)
|
|
202
|
+
if not manifests:
|
|
203
|
+
if last_error is not None:
|
|
204
|
+
raise RuntimeError(f"no service manifests available from @aster ({last_error})")
|
|
205
|
+
raise RuntimeError("no service manifests available from @aster")
|
|
206
|
+
|
|
207
|
+
temp_dir = tempfile.mkdtemp(prefix="aster-cli-runtime-")
|
|
208
|
+
package_name = f"aster_cli_runtime_{os.getpid()}"
|
|
209
|
+
generate_python_clients(
|
|
210
|
+
manifests,
|
|
211
|
+
out_dir=temp_dir,
|
|
212
|
+
namespace=package_name,
|
|
213
|
+
source=self.address,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
sys.path.insert(0, temp_dir)
|
|
217
|
+
try:
|
|
218
|
+
signed_mod = importlib.import_module(
|
|
219
|
+
f"{package_name}.types.signed_request"
|
|
220
|
+
)
|
|
221
|
+
profile_mod = importlib.import_module(
|
|
222
|
+
f"{package_name}.services.profile_service_v1"
|
|
223
|
+
)
|
|
224
|
+
publication_mod = importlib.import_module(
|
|
225
|
+
f"{package_name}.services.publication_service_v1"
|
|
226
|
+
)
|
|
227
|
+
access_mod = importlib.import_module(
|
|
228
|
+
f"{package_name}.services.access_service_v1"
|
|
229
|
+
)
|
|
230
|
+
except Exception:
|
|
231
|
+
with contextlib.suppress(ValueError):
|
|
232
|
+
sys.path.remove(temp_dir)
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
self._peer = peer
|
|
236
|
+
self._temp_dir = temp_dir
|
|
237
|
+
self._package_name = package_name
|
|
238
|
+
self._types_signed_request = signed_mod.SignedRequest
|
|
239
|
+
self._profile_client_cls = profile_mod.ProfileServiceClient
|
|
240
|
+
self._publication_client_cls = publication_mod.PublicationServiceClient
|
|
241
|
+
self._access_client_cls = access_mod.AccessServiceClient
|
|
242
|
+
self._loaded = True
|
|
243
|
+
|
|
244
|
+
async def close(self) -> None:
|
|
245
|
+
if self._peer and getattr(self._peer, "_aster_client", None) is not None:
|
|
246
|
+
with contextlib.suppress(Exception):
|
|
247
|
+
await self._peer._aster_client.close()
|
|
248
|
+
self._clients.clear()
|
|
249
|
+
if self._temp_dir:
|
|
250
|
+
with contextlib.suppress(ValueError):
|
|
251
|
+
sys.path.remove(self._temp_dir)
|
|
252
|
+
|
|
253
|
+
async def __aenter__(self) -> AsterServiceRuntime:
|
|
254
|
+
await self.connect()
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
258
|
+
await self.close()
|
|
259
|
+
|
|
260
|
+
def signed_request(self, envelope: SignedEnvelope) -> Any:
|
|
261
|
+
if not self._loaded or self._types_signed_request is None:
|
|
262
|
+
raise RuntimeError("runtime clients not loaded")
|
|
263
|
+
return self._types_signed_request(
|
|
264
|
+
payload_json=envelope.payload_json,
|
|
265
|
+
signer_pubkey=envelope.signer_pubkey,
|
|
266
|
+
signature=envelope.signature,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def profile_client(self) -> Any:
|
|
270
|
+
if "profile" not in self._clients:
|
|
271
|
+
if self._profile_client_cls is None or self._peer is None:
|
|
272
|
+
raise RuntimeError("runtime clients not loaded")
|
|
273
|
+
self._clients["profile"] = await self._profile_client_cls.from_connection(
|
|
274
|
+
self._peer._aster_client
|
|
275
|
+
)
|
|
276
|
+
return self._clients["profile"]
|
|
277
|
+
|
|
278
|
+
async def publication_client(self) -> Any:
|
|
279
|
+
if "publication" not in self._clients:
|
|
280
|
+
if self._publication_client_cls is None or self._peer is None:
|
|
281
|
+
raise RuntimeError("runtime clients not loaded")
|
|
282
|
+
self._clients["publication"] = await self._publication_client_cls.from_connection(
|
|
283
|
+
self._peer._aster_client
|
|
284
|
+
)
|
|
285
|
+
return self._clients["publication"]
|
|
286
|
+
|
|
287
|
+
async def access_client(self) -> Any:
|
|
288
|
+
if "access" not in self._clients:
|
|
289
|
+
if self._access_client_cls is None or self._peer is None:
|
|
290
|
+
raise RuntimeError("runtime clients not loaded")
|
|
291
|
+
self._clients["access"] = await self._access_client_cls.from_connection(
|
|
292
|
+
self._peer._aster_client
|
|
293
|
+
)
|
|
294
|
+
return self._clients["access"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def open_aster_service(explicit_address: str | None = None) -> AsterServiceRuntime:
|
|
298
|
+
runtime = AsterServiceRuntime(resolve_aster_service_address(explicit_address))
|
|
299
|
+
await runtime.connect()
|
|
300
|
+
return runtime
|