aac-cli 0.2.5__tar.gz → 0.2.6__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.
- {aac_cli-0.2.5/aac_cli.egg-info → aac_cli-0.2.6}/PKG-INFO +9 -4
- {aac_cli-0.2.5 → aac_cli-0.2.6}/README.md +8 -3
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/__init__.py +1 -1
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/cli.py +74 -16
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/data/renderer-source.json +2 -2
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/aeg_cli.py +4 -1
- aac_cli-0.2.6/aac_cli/aeg_list.py +173 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/agent_layout.py +0 -18
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/agent_record.py +1 -1
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/cli.py +0 -7
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/config.py +12 -85
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/init_cli.py +0 -3
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/registration_state.py +9 -48
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/trace_query.py +20 -5
- {aac_cli-0.2.5 → aac_cli-0.2.6/aac_cli.egg-info}/PKG-INFO +9 -4
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli.egg-info/SOURCES.txt +1 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/pyproject.toml +1 -1
- {aac_cli-0.2.5 → aac_cli-0.2.6}/LICENSE +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/MANIFEST.in +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/NOTICE +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/THIRD_PARTY_NOTICES.md +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/__main__.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/__init__.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/data/action-taken-v1.json +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/data/predicates-v1.json +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/from_sidecar.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/inputs.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/model.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/narrative.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/schemas.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/temporal.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/timeline_source.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/_aeg/visualizer.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/admin_key_pem.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/agent_cli.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/agent_config.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/agent_health.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/api_key_rotation_state.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/chain_list.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/config_render.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/dev_material.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/idp_recovery.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/material_cases.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/profiles.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/reference.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/secure_files.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/sso_login.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli/supplied_material.py +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli.egg-info/dependency_links.txt +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli.egg-info/requires.txt +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/aac_cli.egg-info/top_level.txt +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/launchers/aac +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/launchers/aeg +0 -0
- {aac_cli-0.2.5 → aac_cli-0.2.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aac-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
|
|
5
5
|
Author: Agent Authority Cloud Project
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -69,8 +69,13 @@ Python 3.11 or newer is required.
|
|
|
69
69
|
|
|
70
70
|
Installing or upgrading `aac-cli` supplies both `aac` and `aeg`.
|
|
71
71
|
Use the [AEG guide and command reference](https://cascadeauth.github.io/aac-starter-guide/aeg.html)
|
|
72
|
-
for
|
|
73
|
-
|
|
72
|
+
for listing local and central observations, rendering evidence, and selecting a profile.
|
|
73
|
+
|
|
74
|
+
`aeg list --profile PROFILE --since 7d` discovers participant-visible central
|
|
75
|
+
roots. Add `--events FILE` and/or `--actions FILE` for a hybrid listing with local
|
|
76
|
+
task references and a `local`, `control-plane` or `both` source column. Omit the
|
|
77
|
+
profile for offline local listing. Central fetches are bounded and expose
|
|
78
|
+
continuation; forwarding remains best-effort. Local files are never uploaded.
|
|
74
79
|
|
|
75
80
|
## Guided setup using `aac init`
|
|
76
81
|
|
|
@@ -381,7 +386,7 @@ issues them for those names or checks supplied certificates against them; renewa
|
|
|
381
386
|
keeps those names. Applying a different name set requires the installed certificate
|
|
382
387
|
to cover it already: supplied issuers can issue a certificate covering both sets
|
|
383
388
|
before the change. A CLI-issued agent needing new names must be created under a
|
|
384
|
-
new name with the intended configuration.
|
|
389
|
+
new name with the intended configuration.
|
|
385
390
|
|
|
386
391
|
Central forwarding retains the local sink and sends only permitted metadata.
|
|
387
392
|
It is asynchronous and best-effort; a local event does not prove central delivery.
|
|
@@ -47,8 +47,13 @@ Python 3.11 or newer is required.
|
|
|
47
47
|
|
|
48
48
|
Installing or upgrading `aac-cli` supplies both `aac` and `aeg`.
|
|
49
49
|
Use the [AEG guide and command reference](https://cascadeauth.github.io/aac-starter-guide/aeg.html)
|
|
50
|
-
for
|
|
51
|
-
|
|
50
|
+
for listing local and central observations, rendering evidence, and selecting a profile.
|
|
51
|
+
|
|
52
|
+
`aeg list --profile PROFILE --since 7d` discovers participant-visible central
|
|
53
|
+
roots. Add `--events FILE` and/or `--actions FILE` for a hybrid listing with local
|
|
54
|
+
task references and a `local`, `control-plane` or `both` source column. Omit the
|
|
55
|
+
profile for offline local listing. Central fetches are bounded and expose
|
|
56
|
+
continuation; forwarding remains best-effort. Local files are never uploaded.
|
|
52
57
|
|
|
53
58
|
## Guided setup using `aac init`
|
|
54
59
|
|
|
@@ -359,7 +364,7 @@ issues them for those names or checks supplied certificates against them; renewa
|
|
|
359
364
|
keeps those names. Applying a different name set requires the installed certificate
|
|
360
365
|
to cover it already: supplied issuers can issue a certificate covering both sets
|
|
361
366
|
before the change. A CLI-issued agent needing new names must be created under a
|
|
362
|
-
new name with the intended configuration.
|
|
367
|
+
new name with the intended configuration.
|
|
363
368
|
|
|
364
369
|
Central forwarding retains the local sink and sends only permitted metadata.
|
|
365
370
|
It is asynchronous and best-effort; a local event does not prove central delivery.
|
|
@@ -39,7 +39,7 @@ def parser(
|
|
|
39
39
|
commands = result.add_subparsers(dest="command", required=True)
|
|
40
40
|
render = commands.add_parser("render", help="Write a self-contained interactive HTML graph.")
|
|
41
41
|
listing = commands.add_parser(
|
|
42
|
-
"list", help="List
|
|
42
|
+
"list", help="List roots from local files, a control-plane profile, or both."
|
|
43
43
|
)
|
|
44
44
|
for command in (render, listing):
|
|
45
45
|
command.add_argument(
|
|
@@ -72,9 +72,18 @@ def parser(
|
|
|
72
72
|
selector.add_argument("--root-token-id", metavar="ID")
|
|
73
73
|
selector.add_argument("--mint-response", type=Path, metavar="FILE")
|
|
74
74
|
render.add_argument("--output", type=Path, required=True, metavar="FILE")
|
|
75
|
-
listing.add_argument(
|
|
75
|
+
listing.add_argument(
|
|
76
|
+
"--profile", help="Query this AAC CLI profile; with files, join by root ID."
|
|
77
|
+
)
|
|
78
|
+
listing.add_argument("--task-ref", help="Exact local task reference; requires local files.")
|
|
79
|
+
listing.add_argument("--limit", type=int, help="Central page size, 1..200 (default 50).")
|
|
80
|
+
listing.add_argument("--max-pages", type=int, help="Central fetch bound, 1..20 (default 1).")
|
|
81
|
+
listing.add_argument("--page-token", help="Continue central enumeration; omit --since.")
|
|
76
82
|
time_start = listing.add_mutually_exclusive_group()
|
|
77
|
-
time_start.add_argument(
|
|
83
|
+
time_start.add_argument(
|
|
84
|
+
"--since",
|
|
85
|
+
help="Look back, e.g. 24h or 7d; online defaults to 24h, maximum 31d. Local also accepts m.",
|
|
86
|
+
)
|
|
78
87
|
time_start.add_argument(
|
|
79
88
|
"--from", dest="from_time", help="Inclusive ISO 8601 observation time with timezone."
|
|
80
89
|
)
|
|
@@ -185,11 +194,21 @@ def _date(value: str) -> datetime:
|
|
|
185
194
|
raise InputError(f"invalid time {value!r}; use ISO 8601 with timezone") from exc
|
|
186
195
|
|
|
187
196
|
|
|
188
|
-
def _list(args, events) -> int:
|
|
197
|
+
def _list(args, events, list_query) -> int:
|
|
198
|
+
if args.profile is not None:
|
|
199
|
+
if not args.profile.strip():
|
|
200
|
+
raise InputError("--profile must name an existing AAC CLI profile")
|
|
201
|
+
if args.task_ref is not None and not (args.events or args.actions):
|
|
202
|
+
raise InputError(
|
|
203
|
+
"--task-ref requires local files; the control plane stores no task references"
|
|
204
|
+
)
|
|
205
|
+
if list_query is None:
|
|
206
|
+
raise InputError("online listing requires the aeg command supplied by aac-cli")
|
|
207
|
+
return list_query(args, events)
|
|
189
208
|
if not (args.events or args.actions):
|
|
190
|
-
raise InputError(
|
|
191
|
-
|
|
192
|
-
)
|
|
209
|
+
raise InputError("supply --events, --actions and/or --profile to list roots")
|
|
210
|
+
if any(value is not None for value in (args.limit, args.max_pages, args.page_token)):
|
|
211
|
+
raise InputError("--limit, --max-pages and --page-token require --profile")
|
|
193
212
|
start = _date(args.from_time) if args.from_time else None
|
|
194
213
|
stop = _date(args.to_time) if args.to_time else None
|
|
195
214
|
if args.since:
|
|
@@ -204,6 +223,13 @@ def _list(args, events) -> int:
|
|
|
204
223
|
raise InputError("--since duration is outside supported range") from exc
|
|
205
224
|
if start and stop and start > stop:
|
|
206
225
|
raise InputError("--from/--since must precede --to")
|
|
226
|
+
rows, diagnostics = local_rows(events, args.task_ref, start, stop)
|
|
227
|
+
print_listing(args.output, rows, diagnostics)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def local_rows(events, task_ref, start, stop):
|
|
232
|
+
"""Select local activity, retaining older evidence for token/root correlation."""
|
|
207
233
|
roots, mapping = roots_and_mapping(events)
|
|
208
234
|
diagnostics = [
|
|
209
235
|
f"{e.raw_event.get('_source', e.source)}: unmatched action {e.token_id}; supply sidecar root mapping"
|
|
@@ -230,7 +256,7 @@ def _list(args, events) -> int:
|
|
|
230
256
|
or (e.raw_event.get("action_payload") or {}).get("task_ref")
|
|
231
257
|
}
|
|
232
258
|
)
|
|
233
|
-
if
|
|
259
|
+
if task_ref is not None and task_ref not in task_refs:
|
|
234
260
|
continue
|
|
235
261
|
observed = [
|
|
236
262
|
e
|
|
@@ -240,36 +266,68 @@ def _list(args, events) -> int:
|
|
|
240
266
|
]
|
|
241
267
|
if not observed:
|
|
242
268
|
continue
|
|
243
|
-
times = sorted(e.timestamp_iso for e in observed)
|
|
269
|
+
times = sorted(_date(e.timestamp_iso) for e in observed)
|
|
244
270
|
rows.append(
|
|
245
271
|
{
|
|
246
272
|
"root_token_id": root,
|
|
247
273
|
"task_refs": task_refs,
|
|
248
|
-
"first_observed": times[0],
|
|
249
|
-
"last_observed": times[-1],
|
|
274
|
+
"first_observed": times[0].isoformat(),
|
|
275
|
+
"last_observed": times[-1].isoformat(),
|
|
250
276
|
"sources": "local",
|
|
251
277
|
"evidence_sources": sorted(
|
|
252
278
|
{str(e.raw_event.get("_source", e.source)) for e in observed}
|
|
253
279
|
),
|
|
254
280
|
}
|
|
255
281
|
)
|
|
256
|
-
|
|
257
|
-
|
|
282
|
+
return rows, diagnostics
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def print_listing(output, rows, diagnostics, coverage=None):
|
|
286
|
+
if output == "json":
|
|
287
|
+
body = {"chains": rows, "diagnostics": diagnostics}
|
|
288
|
+
if coverage is not None:
|
|
289
|
+
body.update(coverage)
|
|
290
|
+
print(json.dumps(body, indent=2))
|
|
258
291
|
else:
|
|
292
|
+
if coverage is not None:
|
|
293
|
+
central = coverage["central"]
|
|
294
|
+
print(
|
|
295
|
+
"Central observations may be incomplete (best-effort forwarding; live pagination)."
|
|
296
|
+
)
|
|
297
|
+
if coverage["from"] is None or coverage["to"] is None:
|
|
298
|
+
print("Activity interval: unknown (continuation failed)")
|
|
299
|
+
else:
|
|
300
|
+
print(f"Activity interval: [{coverage['from']}, {coverage['to']})")
|
|
301
|
+
print(f"Central fetch: {central['status']}; {central['pages_fetched']} page(s).")
|
|
302
|
+
print("Sources describe this query; local does not mean never received centrally.")
|
|
303
|
+
print(
|
|
304
|
+
"Times and hop information are observed, not execution or business-completion totals."
|
|
305
|
+
)
|
|
259
306
|
print("ROOT TOKEN ID | TASK REFERENCES | FIRST OBSERVED (UTC) | SOURCES")
|
|
260
307
|
for row in rows:
|
|
261
308
|
print(
|
|
262
|
-
f"{row['root_token_id']} | {', '.join(row['task_refs']) or '(unknown)'} | {row['first_observed']} |
|
|
309
|
+
f"{row['root_token_id']} | {', '.join(row['task_refs']) or '(unknown)'} | {row['first_observed']} | {row['sources']}"
|
|
263
310
|
)
|
|
311
|
+
if coverage is not None and coverage["central"]["next_page_token"]:
|
|
312
|
+
cursor = coverage["central"]["next_page_token"]
|
|
313
|
+
if coverage["central"]["status"] == "failed":
|
|
314
|
+
print(
|
|
315
|
+
f"Retry with --page-token {cursor} only if the failure was transient; "
|
|
316
|
+
"otherwise restart the original query."
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
print(
|
|
320
|
+
f"Continue with --page-token {cursor} (same profile/files/filter; omit --since)."
|
|
321
|
+
)
|
|
264
322
|
for diagnostic in diagnostics:
|
|
265
323
|
print(diagnostic, file=sys.stderr)
|
|
266
|
-
return 0
|
|
267
324
|
|
|
268
325
|
|
|
269
326
|
def main(
|
|
270
327
|
argv: list[str] | None = None,
|
|
271
328
|
*,
|
|
272
329
|
query: Query | None = None,
|
|
330
|
+
list_query: Callable[[argparse.Namespace, list], int] | None = None,
|
|
273
331
|
prog: str = "aeg",
|
|
274
332
|
version_text: str = "shared AAC renderer",
|
|
275
333
|
) -> int:
|
|
@@ -280,7 +338,7 @@ def main(
|
|
|
280
338
|
return (
|
|
281
339
|
_render(args, events, query or _query)
|
|
282
340
|
if args.command == "render"
|
|
283
|
-
else _list(args, events)
|
|
341
|
+
else _list(args, events, list_query)
|
|
284
342
|
)
|
|
285
343
|
except (InputError, ValueError, OSError) as exc:
|
|
286
344
|
for diagnostic in unjoinable_diagnostics(events):
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"content_sha256": "
|
|
2
|
+
"content_sha256": "f0477c0f8e73bffe5e111842d8aca3f5745345acb8cb58bed911deba7d826798",
|
|
3
3
|
"files": {
|
|
4
4
|
"__init__.py": "80d80a1ba35353d271cddf02532801c60b57f5ec06fb268ae823747742ce8979",
|
|
5
|
-
"cli.py": "
|
|
5
|
+
"cli.py": "8fa9605246ba0ddbd24e6034b9582049fac11df139d47ee9e2ee0f322ff53c4c",
|
|
6
6
|
"data/action-taken-v1.json": "d863343afe7f314646a73cf063480a7e650f93857d2ed5ec3a7b1f9838671fdf",
|
|
7
7
|
"data/predicates-v1.json": "35e83a29feada10e8cfbef7009245146a9717d4f4fd58d74124e0ec91ae922c8",
|
|
8
8
|
"from_sidecar.py": "6ac023f6925a13e94e0fee762656a02ca98981f2add194b052e9014ca48e2219",
|
|
@@ -21,6 +21,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
21
21
|
return 0
|
|
22
22
|
from ._aeg import cli
|
|
23
23
|
from ._aeg.inputs import InputError, read_trace
|
|
24
|
+
from .aeg_list import list_chains
|
|
24
25
|
|
|
25
26
|
def query(profile: str, root: str):
|
|
26
27
|
from .trace_query import query_trace
|
|
@@ -30,7 +31,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
30
31
|
except (ValueError, TypeError) as exc:
|
|
31
32
|
raise InputError(str(exc)) from exc
|
|
32
33
|
|
|
33
|
-
return cli.main(
|
|
34
|
+
return cli.main(
|
|
35
|
+
argv, query=query, list_query=list_chains, prog="aeg", version_text=version_text()
|
|
36
|
+
)
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def entrypoint() -> None:
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Bounded AEG discovery over the public chain-list JSON contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from ._aeg.cli import _date, local_rows, print_listing
|
|
8
|
+
from ._aeg.inputs import InputError, token_id
|
|
9
|
+
from .chain_list import query_params
|
|
10
|
+
from .trace_query import query_chains
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _observed_time(value) -> str:
|
|
14
|
+
if type(value) is not int:
|
|
15
|
+
raise ValueError("expected integer observation time")
|
|
16
|
+
return datetime.fromtimestamp(value, timezone.utc).isoformat()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _page(body, params, previous_root):
|
|
20
|
+
"""Validate the complete page before adding any of its rows to the result."""
|
|
21
|
+
try:
|
|
22
|
+
if (
|
|
23
|
+
type(body["schema_version"]) is not int
|
|
24
|
+
or body["schema_version"] != 1
|
|
25
|
+
or body["pagination"] != "live"
|
|
26
|
+
or body["central_evidence"] != "best_effort"
|
|
27
|
+
):
|
|
28
|
+
raise ValueError("expected chain-list JSON v1 with live/best-effort evidence")
|
|
29
|
+
if not isinstance(body["from"], str) or not isinstance(body["to"], str):
|
|
30
|
+
raise ValueError("expected resolved central interval")
|
|
31
|
+
window = query_params(
|
|
32
|
+
since=None,
|
|
33
|
+
start=body["from"],
|
|
34
|
+
end=body["to"],
|
|
35
|
+
limit=body["limit"],
|
|
36
|
+
page_token=None,
|
|
37
|
+
)
|
|
38
|
+
if type(body["limit"]) is not int:
|
|
39
|
+
raise ValueError("expected integer page limit")
|
|
40
|
+
if any(window[key] != value for key, value in params.items() if key != "cursor"):
|
|
41
|
+
raise ValueError("central response changed the requested interval or limit")
|
|
42
|
+
more, cursor = body["has_more"], body["next_page_token"]
|
|
43
|
+
if (
|
|
44
|
+
type(more) is not bool
|
|
45
|
+
or (more and (not isinstance(cursor, str) or not 1 <= len(cursor) <= 2048))
|
|
46
|
+
or (not more and cursor is not None)
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("invalid central continuation")
|
|
49
|
+
if not isinstance(body["chains"], list) or len(body["chains"]) > body["limit"]:
|
|
50
|
+
raise ValueError("invalid central page rows")
|
|
51
|
+
if more and not body["chains"]:
|
|
52
|
+
raise ValueError("empty central page has a continuation")
|
|
53
|
+
rows = []
|
|
54
|
+
for item in body["chains"]:
|
|
55
|
+
root = token_id(item["root_token_id"], "central root")
|
|
56
|
+
if root <= previous_root:
|
|
57
|
+
raise ValueError("central roots must advance in ascending order")
|
|
58
|
+
previous_root = root
|
|
59
|
+
first = _observed_time(item["first_event_unix_seconds"])
|
|
60
|
+
last = _observed_time(item["last_event_unix_seconds"])
|
|
61
|
+
if first > last:
|
|
62
|
+
raise ValueError("reversed central observation times")
|
|
63
|
+
for key in ("participant_tenant_ids", "event_categories", "outcomes"):
|
|
64
|
+
if not isinstance(item[key], list) or not all(
|
|
65
|
+
isinstance(v, str) for v in item[key]
|
|
66
|
+
):
|
|
67
|
+
raise ValueError(f"invalid central {key}")
|
|
68
|
+
for key in ("observed_token_count", "max_observed_hop"):
|
|
69
|
+
if type(item[key]) is not int or item[key] < 0:
|
|
70
|
+
raise ValueError(f"invalid central {key}")
|
|
71
|
+
rows.append(
|
|
72
|
+
{
|
|
73
|
+
"root_token_id": root,
|
|
74
|
+
"task_refs": [],
|
|
75
|
+
"first_observed": first,
|
|
76
|
+
"last_observed": last,
|
|
77
|
+
"sources": "control-plane",
|
|
78
|
+
"evidence_sources": ["control-plane"],
|
|
79
|
+
"central_observations": item,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return window, rows, more, cursor, previous_root
|
|
83
|
+
except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
|
|
84
|
+
raise InputError(f"incompatible chain-list JSON: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_chains(args, events) -> int:
|
|
88
|
+
max_pages = args.max_pages if args.max_pages is not None else 1
|
|
89
|
+
if not 1 <= max_pages <= 20:
|
|
90
|
+
raise InputError("--max-pages must be between 1 and 20")
|
|
91
|
+
# Resolve relative time once before any I/O; reuse it for local input and
|
|
92
|
+
# every page. A bare cursor obtains its bound interval from the first page.
|
|
93
|
+
params = query_params(
|
|
94
|
+
since=args.since,
|
|
95
|
+
start=args.from_time,
|
|
96
|
+
end=args.to_time,
|
|
97
|
+
limit=args.limit,
|
|
98
|
+
page_token=args.page_token,
|
|
99
|
+
)
|
|
100
|
+
window = dict(params)
|
|
101
|
+
central_rows = []
|
|
102
|
+
diagnostics = []
|
|
103
|
+
pages = 0
|
|
104
|
+
more = None
|
|
105
|
+
cursor = args.page_token
|
|
106
|
+
status = "failed"
|
|
107
|
+
previous_root = ""
|
|
108
|
+
seen_cursors = {cursor} if cursor is not None else set()
|
|
109
|
+
for _ in range(max_pages):
|
|
110
|
+
try:
|
|
111
|
+
body = query_chains(args.profile, params)
|
|
112
|
+
next_window, rows, next_more, next_cursor, last_root = _page(
|
|
113
|
+
body, params, previous_root
|
|
114
|
+
)
|
|
115
|
+
if next_cursor is not None and next_cursor in seen_cursors:
|
|
116
|
+
raise InputError("incompatible chain-list JSON: repeated continuation")
|
|
117
|
+
except (ValueError, TypeError) as exc:
|
|
118
|
+
diagnostics.append(str(exc))
|
|
119
|
+
status = "failed"
|
|
120
|
+
break
|
|
121
|
+
window = next_window
|
|
122
|
+
central_rows.extend(rows)
|
|
123
|
+
pages += 1
|
|
124
|
+
more, cursor, previous_root = next_more, next_cursor, last_root
|
|
125
|
+
status = "more" if more else "exhausted"
|
|
126
|
+
if not more:
|
|
127
|
+
break
|
|
128
|
+
seen_cursors.add(cursor)
|
|
129
|
+
params = {**window, "cursor": cursor}
|
|
130
|
+
|
|
131
|
+
if "from" in window and "to" in window:
|
|
132
|
+
rows, local_diagnostics = local_rows(
|
|
133
|
+
events, args.task_ref, _date(window["from"]), _date(window["to"])
|
|
134
|
+
)
|
|
135
|
+
diagnostics.extend(local_diagnostics)
|
|
136
|
+
else:
|
|
137
|
+
rows = []
|
|
138
|
+
diagnostics.append(
|
|
139
|
+
"Continuation interval unavailable; local rows cannot be selected. Retry the original interval."
|
|
140
|
+
)
|
|
141
|
+
joined = {row["root_token_id"]: row for row in rows}
|
|
142
|
+
for central in central_rows:
|
|
143
|
+
root = central["root_token_id"]
|
|
144
|
+
local = joined.get(root)
|
|
145
|
+
if local is not None:
|
|
146
|
+
local.update(
|
|
147
|
+
{
|
|
148
|
+
"sources": "both",
|
|
149
|
+
"first_observed": min(local["first_observed"], central["first_observed"]),
|
|
150
|
+
"last_observed": max(local["last_observed"], central["last_observed"]),
|
|
151
|
+
"evidence_sources": sorted([*local["evidence_sources"], "control-plane"]),
|
|
152
|
+
"central_observations": central["central_observations"],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
elif args.task_ref is None:
|
|
156
|
+
joined[root] = central
|
|
157
|
+
coverage = {
|
|
158
|
+
"schema_version": 1,
|
|
159
|
+
"mode": "hybrid" if args.events or args.actions else "control-plane",
|
|
160
|
+
"from": window.get("from"),
|
|
161
|
+
"to": window.get("to"),
|
|
162
|
+
"central": {
|
|
163
|
+
"status": status,
|
|
164
|
+
"pages_fetched": pages,
|
|
165
|
+
"limit": int(window["limit"]) if "limit" in window else None,
|
|
166
|
+
"has_more": more,
|
|
167
|
+
"next_page_token": cursor,
|
|
168
|
+
"pagination": "live",
|
|
169
|
+
"evidence": "best_effort",
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
print_listing(args.output, [joined[root] for root in sorted(joined)], diagnostics, coverage)
|
|
173
|
+
return 4 if status == "failed" else 0
|
|
@@ -29,9 +29,6 @@ from aac_cli.material_cases import MATERIAL_ISSUED_HERE, MATERIAL_SUPPLIED
|
|
|
29
29
|
|
|
30
30
|
TENANTS_DIRECTORY = "tenants"
|
|
31
31
|
AGENTS_DIRECTORY = "agents"
|
|
32
|
-
# 0.1.x kept the same material under `workspaces/`. 0.2.0 is a clean break: an
|
|
33
|
-
# old folder is recognised only so the CLI can say so in one line (B270).
|
|
34
|
-
LEGACY_AGENTS_DIRECTORY = "workspaces"
|
|
35
32
|
|
|
36
33
|
# Where the container layout expects the mounted directories (matches the
|
|
37
34
|
# public starter guide's Docker recipe so B240 can reuse its mounts).
|
|
@@ -249,21 +246,6 @@ def agent_paths(name: str, home: Path | None = None) -> AgentPaths:
|
|
|
249
246
|
return AgentPaths(name=name, root=(home or cli_home()) / AGENTS_DIRECTORY / name)
|
|
250
247
|
|
|
251
248
|
|
|
252
|
-
def legacy_agent_directory(name: str, home: Path | None = None) -> Path:
|
|
253
|
-
"""Where 0.1.x kept this name's material. Read only to refuse it (B270)."""
|
|
254
|
-
return (home or cli_home()) / LEGACY_AGENTS_DIRECTORY / name
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def refuse_legacy_directory(name: str, home: Path | None = None) -> None:
|
|
258
|
-
"""One line, no migration: development material is disposable by design."""
|
|
259
|
-
legacy = legacy_agent_directory(name, home)
|
|
260
|
-
if legacy.exists():
|
|
261
|
-
raise CliConfigError(
|
|
262
|
-
f"{legacy} is from an older CLI and is not migrated; create a new agent with "
|
|
263
|
-
f"`aac init --agent {name}` after deleting it, or choose another name"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
249
|
def tenant_paths(tenant_id: str, home: Path | None = None) -> TenantPaths:
|
|
268
250
|
if not CANONICAL_TENANT_ID_PATTERN.fullmatch(tenant_id):
|
|
269
251
|
raise CliConfigError(f"tenant id {tenant_id!r} is not canonical; refusing to derive a path")
|
|
@@ -153,7 +153,7 @@ def read_record(path: Path) -> AgentRecord:
|
|
|
153
153
|
raise CliConfigError(
|
|
154
154
|
f"agent record {path} has an unsupported schema_version; "
|
|
155
155
|
f"this CLI understands version {RECORD_SCHEMA_VERSION}. Create a fresh agent "
|
|
156
|
-
"under a different name
|
|
156
|
+
"under a different name. The existing record is unchanged."
|
|
157
157
|
)
|
|
158
158
|
try:
|
|
159
159
|
record = AgentRecord(**document)
|
|
@@ -146,7 +146,6 @@ from aac_cli.registration_state import (
|
|
|
146
146
|
profile_registration_lock,
|
|
147
147
|
read_registration_state,
|
|
148
148
|
registration_state_path,
|
|
149
|
-
upgrade_registration_state,
|
|
150
149
|
validate_tenant_domain,
|
|
151
150
|
write_registration_state,
|
|
152
151
|
)
|
|
@@ -666,12 +665,6 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
666
665
|
config = _resolve(args)
|
|
667
666
|
normalized_admin_url = normalize_admin_url(config.admin_url)
|
|
668
667
|
state = read_registration_state(profile, expected_admin_url=normalized_admin_url)
|
|
669
|
-
if state is not None and state.schema_version == 1:
|
|
670
|
-
# B121: v1 had no tenant_domains field. Upgrade only after the strict
|
|
671
|
-
# v1 reader verified its original digest and normalized empty-domain
|
|
672
|
-
# intent; every subsequent write is schema v2.
|
|
673
|
-
state = replace(upgrade_registration_state(state), updated_at=_utc_now())
|
|
674
|
-
write_registration_state(state)
|
|
675
668
|
bound_tenant_id = read_profile_section(profile).get("tenant_id")
|
|
676
669
|
|
|
677
670
|
if state is not None and state.state == "completed_credential_reissue_required":
|
|
@@ -16,9 +16,8 @@ with ``AAC_CLI_HOME`` — tests and multi-account operators):
|
|
|
16
16
|
|
|
17
17
|
``main`` is the RESERVED baseline profile (§3.3): always the final
|
|
18
18
|
selection fallback, virtual (built-in defaults) until
|
|
19
|
-
``aac profile update main`` materializes it.
|
|
20
|
-
|
|
21
|
-
(§8) — fail-closed when both exist with different values.
|
|
19
|
+
``aac profile update main`` materializes it. Reading configuration
|
|
20
|
+
never changes the file or creates the CLI home (§8).
|
|
22
21
|
|
|
23
22
|
TWO urls, not one base: the admin and data-plane surfaces genuinely
|
|
24
23
|
live on different ports (Eng Spec §XVI) and 1C may split them into
|
|
@@ -59,7 +58,6 @@ import fcntl
|
|
|
59
58
|
import json
|
|
60
59
|
import os
|
|
61
60
|
import re
|
|
62
|
-
import sys
|
|
63
61
|
import tempfile
|
|
64
62
|
from collections.abc import Generator
|
|
65
63
|
from contextlib import contextmanager
|
|
@@ -110,9 +108,6 @@ _DEFAULT_DATA_PLANE_URL = "http://127.0.0.1:9000"
|
|
|
110
108
|
# selection fallback; virtual until materialized; create/delete refuse.
|
|
111
109
|
RESERVED_BASELINE_PROFILE = "main"
|
|
112
110
|
|
|
113
|
-
# The prototype-era baseline section name that §8 migrates to [main].
|
|
114
|
-
_LEGACY_BASELINE_SECTION = "default"
|
|
115
|
-
|
|
116
111
|
# Profile-name grammar, spec-exact (§5.3): 3–15 chars, lowercase
|
|
117
112
|
# alphanumeric + single interior hyphens, at least one letter.
|
|
118
113
|
PROFILE_NAME_PATTERN = re.compile(r"^(?=.{3,15}$)(?=.*[a-z])[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -260,8 +255,8 @@ def select_profile(profile_flag: str | None) -> ProfileSelection:
|
|
|
260
255
|
|
|
261
256
|
|
|
262
257
|
# ---------------------------------------------------------------------------
|
|
263
|
-
# Config file loading: parse → reject [DEFAULT] →
|
|
264
|
-
#
|
|
258
|
+
# Config file loading: parse → reject [DEFAULT] → validate names →
|
|
259
|
+
# guardrails. EVERY reader and writer goes through
|
|
265
260
|
# load_validated_config so no path can act on config the contract
|
|
266
261
|
# rejects (Spec §5, §8).
|
|
267
262
|
# ---------------------------------------------------------------------------
|
|
@@ -269,7 +264,7 @@ def select_profile(profile_flag: str | None) -> ProfileSelection:
|
|
|
269
264
|
|
|
270
265
|
@contextmanager
|
|
271
266
|
def _config_mutation_lock() -> Generator[None, None, None]:
|
|
272
|
-
"""Serialize each config
|
|
267
|
+
"""Serialize each config writer's read-modify-replace window."""
|
|
273
268
|
home = cli_home()
|
|
274
269
|
home.mkdir(parents=True, exist_ok=True)
|
|
275
270
|
home.chmod(0o700)
|
|
@@ -329,61 +324,6 @@ def _parse_config_text(config_path: Path) -> configparser.ConfigParser:
|
|
|
329
324
|
return parser
|
|
330
325
|
|
|
331
326
|
|
|
332
|
-
def _migrate_legacy_default_section(
|
|
333
|
-
parser: configparser.ConfigParser, config_path: Path
|
|
334
|
-
) -> configparser.ConfigParser:
|
|
335
|
-
"""§8: [default] → [main], atomically, on first config touch.
|
|
336
|
-
|
|
337
|
-
Three cases: rename when only [default] exists; drop [default]
|
|
338
|
-
when both exist with IDENTICAL values (report once); fail closed
|
|
339
|
-
when both exist with different values — the CLI must not guess,
|
|
340
|
-
merge, or discard either profile. The migration is a WRITE even on
|
|
341
|
-
read-only commands; that is deliberate spec behavior (§8) so every
|
|
342
|
-
PR 2 command observes the post-migration file.
|
|
343
|
-
"""
|
|
344
|
-
# Guardrails already ran on the parsed file (review M1): §8 step 1
|
|
345
|
-
# validates WITHOUT modification, so an invalid file is rejected
|
|
346
|
-
# with exit 3 before this function ever rewrites it.
|
|
347
|
-
has_legacy = parser.has_section(_LEGACY_BASELINE_SECTION)
|
|
348
|
-
if not has_legacy:
|
|
349
|
-
return parser
|
|
350
|
-
has_main = parser.has_section(RESERVED_BASELINE_PROFILE)
|
|
351
|
-
if has_main:
|
|
352
|
-
legacy_items = dict(parser.items(_LEGACY_BASELINE_SECTION))
|
|
353
|
-
main_items = dict(parser.items(RESERVED_BASELINE_PROFILE))
|
|
354
|
-
if legacy_items != main_items:
|
|
355
|
-
raise CliConfigError(
|
|
356
|
-
f"config migration conflict in {config_path}: both "
|
|
357
|
-
f"[default] and [main] exist with DIFFERENT values. AAC "
|
|
358
|
-
f"will not guess which is authoritative. Fix by hand: "
|
|
359
|
-
f"edit the file, keep the values you want under [main], "
|
|
360
|
-
f"delete the [default] section, and re-run."
|
|
361
|
-
)
|
|
362
|
-
migrated = configparser.ConfigParser(interpolation=None)
|
|
363
|
-
for section in parser.sections():
|
|
364
|
-
if section == _LEGACY_BASELINE_SECTION:
|
|
365
|
-
if has_main:
|
|
366
|
-
continue # identical duplicate — [main] already carries it
|
|
367
|
-
target = RESERVED_BASELINE_PROFILE
|
|
368
|
-
else:
|
|
369
|
-
target = section
|
|
370
|
-
migrated.add_section(target)
|
|
371
|
-
for key, value in parser.items(section):
|
|
372
|
-
migrated[target][key] = value
|
|
373
|
-
_write_parser_atomically(migrated, config_path)
|
|
374
|
-
if has_main:
|
|
375
|
-
print(
|
|
376
|
-
f"config migration: removed [default] (identical to [main]) in {config_path}",
|
|
377
|
-
file=sys.stderr,
|
|
378
|
-
)
|
|
379
|
-
else:
|
|
380
|
-
print(
|
|
381
|
-
f"config migration: renamed [default] to [main] in {config_path}",
|
|
382
|
-
file=sys.stderr,
|
|
383
|
-
)
|
|
384
|
-
return migrated
|
|
385
|
-
|
|
386
|
-
|
|
387
327
|
def _enforce_guardrails(parser: configparser.ConfigParser, config_path: Path) -> None:
|
|
388
328
|
"""§5.2 bounded-complexity limits + §5.3 name grammar, fail closed.
|
|
389
329
|
|
|
@@ -391,14 +331,10 @@ def _enforce_guardrails(parser: configparser.ConfigParser, config_path: Path) ->
|
|
|
391
331
|
echoing the value (it may be sensitive). Limits are measured on the
|
|
392
332
|
decoded Python strings configparser produced.
|
|
393
333
|
|
|
394
|
-
|
|
395
|
-
hold the legacy [default] section — it is the pre-migration
|
|
396
|
-
baseline and counts/validates like [main], never as a named
|
|
397
|
-
profile.
|
|
334
|
+
Only [main] is the baseline; every other section is a named profile.
|
|
398
335
|
"""
|
|
399
336
|
sections = parser.sections()
|
|
400
|
-
|
|
401
|
-
non_main = [s for s in sections if s not in baseline_sections]
|
|
337
|
+
non_main = [s for s in sections if s != RESERVED_BASELINE_PROFILE]
|
|
402
338
|
if len(non_main) > MAX_PROFILE_COUNT - 1:
|
|
403
339
|
raise CliConfigError(
|
|
404
340
|
f"invalid config {config_path}: {len(non_main)} named profiles "
|
|
@@ -406,7 +342,7 @@ def _enforce_guardrails(parser: configparser.ConfigParser, config_path: Path) ->
|
|
|
406
342
|
f"besides the reserved [main])"
|
|
407
343
|
)
|
|
408
344
|
for section in sections:
|
|
409
|
-
if section
|
|
345
|
+
if section != RESERVED_BASELINE_PROFILE and not PROFILE_NAME_PATTERN.fullmatch(section):
|
|
410
346
|
raise CliConfigError(
|
|
411
347
|
f"invalid config {config_path}: section [{section}] is not "
|
|
412
348
|
f"a valid profile name — 3–15 characters, lowercase a–z, "
|
|
@@ -434,7 +370,7 @@ def _enforce_guardrails(parser: configparser.ConfigParser, config_path: Path) ->
|
|
|
434
370
|
f"character limit (value not shown)"
|
|
435
371
|
)
|
|
436
372
|
if key == "tenant_id" and CANONICAL_TENANT_ID_PATTERN.fullmatch(value) is None:
|
|
437
|
-
# B154 PR 5 (Spec §5.4/§8
|
|
373
|
+
# B154 PR 5 (Spec §5.4/§8): canonical validation is
|
|
438
374
|
# MANDATORY for every bound profile — the PR-2
|
|
439
375
|
# prototype-era compatibility (deprecation warning) is
|
|
440
376
|
# removed. Hand-edited values are untrusted input.
|
|
@@ -471,25 +407,16 @@ def validate_hosted_trust_domain(tenant_id: str | None, value: str) -> str:
|
|
|
471
407
|
|
|
472
408
|
|
|
473
409
|
def _load_validated_config_unlocked() -> configparser.ConfigParser:
|
|
474
|
-
"""
|
|
475
|
-
|
|
476
|
-
Every reader and writer calls this, so migration runs on the first
|
|
477
|
-
PR 2 command that touches configuration (§8) and no code path ever
|
|
478
|
-
acts on config that fails the §5 contract. Validation precedes
|
|
479
|
-
migration (§8 step 1 / review M1): an over-limit or malformed file
|
|
480
|
-
is rejected without being rewritten.
|
|
481
|
-
"""
|
|
410
|
+
"""Parse and validate one config snapshot without modifying the filesystem."""
|
|
482
411
|
config_path = cli_home() / "config"
|
|
483
412
|
parser = _parse_config_text(config_path)
|
|
484
413
|
_enforce_guardrails(parser, config_path)
|
|
485
|
-
parser = _migrate_legacy_default_section(parser, config_path)
|
|
486
414
|
return parser
|
|
487
415
|
|
|
488
416
|
|
|
489
417
|
def load_validated_config() -> configparser.ConfigParser:
|
|
490
|
-
"""
|
|
491
|
-
|
|
492
|
-
return _load_validated_config_unlocked()
|
|
418
|
+
"""Read one snapshot; atomic replacement keeps concurrent writes untorn."""
|
|
419
|
+
return _load_validated_config_unlocked()
|
|
493
420
|
|
|
494
421
|
|
|
495
422
|
def _write_parser_atomically(parser: configparser.ConfigParser, config_path: Path) -> None:
|
|
@@ -50,7 +50,6 @@ from aac_cli.agent_layout import (
|
|
|
50
50
|
TenantPaths,
|
|
51
51
|
agent_paths,
|
|
52
52
|
pending_admin_key_paths,
|
|
53
|
-
refuse_legacy_directory,
|
|
54
53
|
tenant_paths,
|
|
55
54
|
)
|
|
56
55
|
from aac_cli.agent_record import (
|
|
@@ -254,7 +253,6 @@ def _load_or_create_record(
|
|
|
254
253
|
args: argparse.Namespace, agent: AgentPaths, profile: str, config: ResolvedConfig
|
|
255
254
|
) -> tuple[AgentRecord, str]:
|
|
256
255
|
"""Return (record, outcome) where outcome is 'created' or 'resumed'."""
|
|
257
|
-
refuse_legacy_directory(agent.name)
|
|
258
256
|
root_problem = managed_path_problem(agent.root, root=cli_home(), kind="directory")
|
|
259
257
|
if root_problem:
|
|
260
258
|
raise CliConfigError(f"refusing to use agent {agent.name!r}: {root_problem}")
|
|
@@ -1790,7 +1788,6 @@ def _refresh_settings(
|
|
|
1790
1788
|
|
|
1791
1789
|
def _configuration_preflight(args: argparse.Namespace, agent: AgentPaths) -> None:
|
|
1792
1790
|
"""Resolve immutable identity and validate input before any profile/remote write."""
|
|
1793
|
-
refuse_legacy_directory(agent.name)
|
|
1794
1791
|
recorded = read_record(agent.record) if agent.record.exists() else None
|
|
1795
1792
|
args.accepted_config, args.accepted_https_cas = None, []
|
|
1796
1793
|
if args.agent_config:
|
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from collections.abc import Generator
|
|
32
32
|
from contextlib import contextmanager
|
|
33
|
-
from dataclasses import asdict, dataclass
|
|
33
|
+
from dataclasses import asdict, dataclass
|
|
34
34
|
from pathlib import Path
|
|
35
35
|
|
|
36
36
|
import httpx
|
|
@@ -58,13 +58,12 @@ __all__ = [
|
|
|
58
58
|
"read_registration_state",
|
|
59
59
|
"registration_state_exists",
|
|
60
60
|
"registration_state_path",
|
|
61
|
-
"upgrade_registration_state",
|
|
62
61
|
"validate_tenant_domain",
|
|
63
62
|
"write_registration_state",
|
|
64
63
|
]
|
|
65
64
|
|
|
66
65
|
SCHEMA_VERSION = 2
|
|
67
|
-
SUPPORTED_SCHEMA_VERSIONS = frozenset({
|
|
66
|
+
SUPPORTED_SCHEMA_VERSIONS = frozenset({SCHEMA_VERSION})
|
|
68
67
|
|
|
69
68
|
# The §9.2 visible warning field — the file is AAC-managed.
|
|
70
69
|
STATE_FILE_WARNING = (
|
|
@@ -339,16 +338,8 @@ def _normalize_existing_registration_intent(
|
|
|
339
338
|
)
|
|
340
339
|
|
|
341
340
|
|
|
342
|
-
def _canonical_local_intent(
|
|
343
|
-
admin_url: str, intent: RegistrationIntent, *, schema_version: int = SCHEMA_VERSION
|
|
344
|
-
) -> str:
|
|
341
|
+
def _canonical_local_intent(admin_url: str, intent: RegistrationIntent) -> str:
|
|
345
342
|
intent_fields = asdict(intent)
|
|
346
|
-
if schema_version == 1:
|
|
347
|
-
# Schema v1 predates B121. Its digest did not contain a business-domain
|
|
348
|
-
# collection; accepting it as empty preserves unresolved registrations.
|
|
349
|
-
intent_fields.pop("tenant_domains")
|
|
350
|
-
elif schema_version != 2:
|
|
351
|
-
raise CliConfigError(f"unsupported registration-state schema_version {schema_version}")
|
|
352
343
|
public_pem = intent_fields.pop("tenant_admin_pubkey_pem")
|
|
353
344
|
intent_fields["tenant_admin_key_id"] = _spki_key_id_from_normalized_pem(public_pem)
|
|
354
345
|
return json.dumps(
|
|
@@ -362,29 +353,8 @@ def _canonical_local_intent(
|
|
|
362
353
|
)
|
|
363
354
|
|
|
364
355
|
|
|
365
|
-
def local_intent_sha256(
|
|
366
|
-
admin_url
|
|
367
|
-
) -> str:
|
|
368
|
-
return hashlib.sha256(
|
|
369
|
-
_canonical_local_intent(admin_url, intent, schema_version=schema_version).encode("utf-8")
|
|
370
|
-
).hexdigest()
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def upgrade_registration_state(state: RegistrationState) -> RegistrationState:
|
|
374
|
-
"""Upgrade a validated v1 state to v2 without changing its intent."""
|
|
375
|
-
if state.schema_version == SCHEMA_VERSION:
|
|
376
|
-
return state
|
|
377
|
-
if state.schema_version != 1:
|
|
378
|
-
raise CliConfigError(f"unsupported schema_version {state.schema_version!r}")
|
|
379
|
-
upgraded = replace(state, schema_version=SCHEMA_VERSION)
|
|
380
|
-
return replace(
|
|
381
|
-
upgraded,
|
|
382
|
-
local_intent_sha256=local_intent_sha256(
|
|
383
|
-
upgraded.control_plane_admin_url,
|
|
384
|
-
upgraded.registration_intent,
|
|
385
|
-
schema_version=SCHEMA_VERSION,
|
|
386
|
-
),
|
|
387
|
-
)
|
|
356
|
+
def local_intent_sha256(admin_url: str, intent: RegistrationIntent) -> str:
|
|
357
|
+
return hashlib.sha256(_canonical_local_intent(admin_url, intent).encode("utf-8")).hexdigest()
|
|
388
358
|
|
|
389
359
|
|
|
390
360
|
def registration_state_dir() -> Path:
|
|
@@ -454,14 +424,13 @@ def _field_violation(state: RegistrationState) -> str | None:
|
|
|
454
424
|
expected_digest = local_intent_sha256(
|
|
455
425
|
state.control_plane_admin_url,
|
|
456
426
|
state.registration_intent,
|
|
457
|
-
schema_version=state.schema_version,
|
|
458
427
|
)
|
|
459
428
|
if not hmac.compare_digest(state.local_intent_sha256, expected_digest):
|
|
460
429
|
return "local_intent_sha256 does not match the frozen normalized intent"
|
|
461
430
|
return None
|
|
462
431
|
|
|
463
432
|
|
|
464
|
-
def _registration_intent_from_raw(raw: object
|
|
433
|
+
def _registration_intent_from_raw(raw: object) -> RegistrationIntent:
|
|
465
434
|
if not isinstance(raw, dict):
|
|
466
435
|
raise TypeError("registration_intent is not an object")
|
|
467
436
|
expected = {
|
|
@@ -473,9 +442,8 @@ def _registration_intent_from_raw(raw: object, *, schema_version: int) -> Regist
|
|
|
473
442
|
"agents",
|
|
474
443
|
"tenant_admin_pubkey_pem",
|
|
475
444
|
"admission",
|
|
445
|
+
"tenant_domains",
|
|
476
446
|
}
|
|
477
|
-
if schema_version == 2:
|
|
478
|
-
expected.add("tenant_domains")
|
|
479
447
|
if set(raw) != expected:
|
|
480
448
|
raise TypeError(f"registration_intent fields differ (expected {sorted(expected)})")
|
|
481
449
|
admission_raw = raw["admission"]
|
|
@@ -487,7 +455,7 @@ def _registration_intent_from_raw(raw: object, *, schema_version: int) -> Regist
|
|
|
487
455
|
raise TypeError("registration_intent.admission has invalid fields")
|
|
488
456
|
workloads_raw = raw["workloads"]
|
|
489
457
|
domains_raw = raw["spiffe_trust_domains"]
|
|
490
|
-
tenant_domains_raw = raw
|
|
458
|
+
tenant_domains_raw = raw["tenant_domains"]
|
|
491
459
|
agents_raw = raw["agents"]
|
|
492
460
|
if (
|
|
493
461
|
not isinstance(workloads_raw, list)
|
|
@@ -632,9 +600,7 @@ def read_registration_state(
|
|
|
632
600
|
attempt_count=raw["attempt_count"],
|
|
633
601
|
control_plane_admin_url=raw["control_plane_admin_url"],
|
|
634
602
|
local_intent_sha256=raw["local_intent_sha256"],
|
|
635
|
-
registration_intent=_registration_intent_from_raw(
|
|
636
|
-
raw["registration_intent"], schema_version=raw_schema_version
|
|
637
|
-
),
|
|
603
|
+
registration_intent=_registration_intent_from_raw(raw["registration_intent"]),
|
|
638
604
|
)
|
|
639
605
|
except (KeyError, TypeError, ValueError, CliConfigError) as exc:
|
|
640
606
|
raise _reject(path, f"missing or mistyped field ({exc})") from exc
|
|
@@ -675,11 +641,6 @@ def write_registration_state(state: RegistrationState) -> Path:
|
|
|
675
641
|
f"unsupported schema_version {state.schema_version!r}; "
|
|
676
642
|
f"this CLI supports {sorted(SUPPORTED_SCHEMA_VERSIONS)}"
|
|
677
643
|
)
|
|
678
|
-
if state.schema_version != SCHEMA_VERSION:
|
|
679
|
-
raise CliConfigError(
|
|
680
|
-
f"refusing to write legacy registration-state schema_version "
|
|
681
|
-
f"{state.schema_version}; upgrade to {SCHEMA_VERSION} first"
|
|
682
|
-
)
|
|
683
644
|
violation = _field_violation(state)
|
|
684
645
|
if violation is not None:
|
|
685
646
|
raise CliConfigError(
|
|
@@ -10,20 +10,35 @@ import sys
|
|
|
10
10
|
|
|
11
11
|
def query_trace(profile: str, root: str) -> dict:
|
|
12
12
|
"""Run the installed CLI's trace command in-process, without discovery."""
|
|
13
|
+
return _query(
|
|
14
|
+
["chain", "show", f"--profile={profile}", f"--token-id={root}", "--output=json"],
|
|
15
|
+
"chain-show",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def query_chains(profile: str, params: dict[str, str]) -> dict:
|
|
20
|
+
"""Consume one public chain-list JSON page using the owning CLI."""
|
|
21
|
+
flags = {"from": "from", "to": "to", "limit": "limit", "cursor": "page-token"}
|
|
22
|
+
return _query(
|
|
23
|
+
["chain", "list", f"--profile={profile}", "--output=json"]
|
|
24
|
+
+ [f"--{flags[key]}={value}" for key, value in params.items()],
|
|
25
|
+
"chain-list",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _query(arguments: list[str], contract: str) -> dict:
|
|
13
30
|
from .cli import main
|
|
14
31
|
|
|
15
32
|
output, errors = io.StringIO(), io.StringIO()
|
|
16
33
|
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
|
|
17
|
-
code = main(
|
|
18
|
-
["chain", "show", f"--profile={profile}", f"--token-id={root}", "--output=json"]
|
|
19
|
-
)
|
|
34
|
+
code = main(arguments)
|
|
20
35
|
if code:
|
|
21
36
|
raise ValueError(f"central query failed (aac exit {code}): {errors.getvalue().strip()}")
|
|
22
37
|
sys.stderr.write(errors.getvalue())
|
|
23
38
|
try:
|
|
24
39
|
body = json.loads(output.getvalue())
|
|
25
40
|
if not isinstance(body, dict):
|
|
26
|
-
raise ValueError("expected
|
|
41
|
+
raise ValueError("expected JSON object")
|
|
27
42
|
return body
|
|
28
43
|
except (ValueError, TypeError) as exc:
|
|
29
|
-
raise ValueError(f"incompatible
|
|
44
|
+
raise ValueError(f"incompatible {contract} JSON: {exc}") from exc
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aac-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
|
|
5
5
|
Author: Agent Authority Cloud Project
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -69,8 +69,13 @@ Python 3.11 or newer is required.
|
|
|
69
69
|
|
|
70
70
|
Installing or upgrading `aac-cli` supplies both `aac` and `aeg`.
|
|
71
71
|
Use the [AEG guide and command reference](https://cascadeauth.github.io/aac-starter-guide/aeg.html)
|
|
72
|
-
for
|
|
73
|
-
|
|
72
|
+
for listing local and central observations, rendering evidence, and selecting a profile.
|
|
73
|
+
|
|
74
|
+
`aeg list --profile PROFILE --since 7d` discovers participant-visible central
|
|
75
|
+
roots. Add `--events FILE` and/or `--actions FILE` for a hybrid listing with local
|
|
76
|
+
task references and a `local`, `control-plane` or `both` source column. Omit the
|
|
77
|
+
profile for offline local listing. Central fetches are bounded and expose
|
|
78
|
+
continuation; forwarding remains best-effort. Local files are never uploaded.
|
|
74
79
|
|
|
75
80
|
## Guided setup using `aac init`
|
|
76
81
|
|
|
@@ -381,7 +386,7 @@ issues them for those names or checks supplied certificates against them; renewa
|
|
|
381
386
|
keeps those names. Applying a different name set requires the installed certificate
|
|
382
387
|
to cover it already: supplied issuers can issue a certificate covering both sets
|
|
383
388
|
before the change. A CLI-issued agent needing new names must be created under a
|
|
384
|
-
new name with the intended configuration.
|
|
389
|
+
new name with the intended configuration.
|
|
385
390
|
|
|
386
391
|
Central forwarding retains the local sink and sends only permitted metadata.
|
|
387
392
|
It is asynchronous and best-effort; a local event does not prove central delivery.
|
|
@@ -32,7 +32,7 @@ build-backend = "setuptools.build_meta"
|
|
|
32
32
|
|
|
33
33
|
[project]
|
|
34
34
|
name = "aac-cli"
|
|
35
|
-
version = "0.2.
|
|
35
|
+
version = "0.2.6"
|
|
36
36
|
description = "The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit)."
|
|
37
37
|
readme = "README.md"
|
|
38
38
|
requires-python = ">=3.11"
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|