oc_chat
Loading...
Searching...
No Matches
agent.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/cli/agent.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_storage_foundation.py"
6"""
7Argparse command surface for the registry operator tools.
8
9Purpose
10-------
11Expose registry operator commands through
12``python -m oc_chat.registry`` before the future ``oct agent`` rename.
13
14Responsibilities
15----------------
16- Parse registry bootstrap command arguments.
17- Resolve the target registry and workspace metadata.
18- Generate seed identifiers and the initial operator credential fingerprint.
19- Call :class:`SQLiteBackend` to apply schema and seed singleton rows.
20- Dispatch doctor and db integrity-check commands.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - lifecycle events
27 L3 - semantic details
28 L4 - deep tracing
29
30Contracts
31---------
32- `init` creates the SQLite registry only; doctor/db commands inspect or
33 repair existing registries.
34- Raw secret bytes are used only to derive the stored fingerprint.
35
36Dependencies
37------------
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.backends.sqlite (registry initialization backend)
40- oc_chat.registry.cli.db (database maintenance command wiring)
41- oc_chat.registry.cli.inspect (read-only inspect command wiring)
42- oc_chat.registry.cli.migrate (legacy migration command wiring)
43- oc_chat.registry.cli.plan (plan domain command wiring)
44- oc_chat.registry.cli.queue (queue domain command wiring)
45- oc_chat.registry.discovery (registry path discovery)
46- oc_chat.core._version (resolve_version stamps the app version into the init seed)
47- oc_chat.registry (Registry facade for the doctor, rotation, handover and auth commands)
48- oc_chat.registry.auth.bootstrap (bootstrap-machine install and export-secrets bundle)
49- oc_chat.registry.auth.handover (initiate and accept orchestrator handover)
50- oc_chat.registry.auth.rotation (start and finalise credential rotation)
51- oc_chat.registry.auth.secrets_store (writes the operator's initial secret block at init)
52- oc_chat.registry.cli.checkpoint (checkpoint export and import command wiring)
53- oc_chat.registry.cli.regenerate (top-level regenerate command wiring)
54- oc_chat.registry.cli.roadmap (roadmap command group wiring)
55- oc_chat.registry.cli.round (round-close command wiring)
56- oc_chat.registry.cli.sync (sync command wiring for registry-backed artefacts)
57- oc_chat.registry.doctor (doctor checks and report formatting)
58"""
59from __future__ import annotations
60
61import argparse
62import hashlib
63import json
64from pathlib import Path
65import secrets
66import socket
67import sys
68import uuid
69
70from oc_diagnostics import _dbg
71
72from oc_chat.core._version import resolve_version
73from oc_chat.registry import Registry
74from oc_chat.registry.auth.bootstrap import bootstrap_machine, export_bundle
75from oc_chat.registry.auth.handover import accept_handover, initiate_handover
76from oc_chat.registry.auth.rotation import finalize_rotation, rotate_secret
77from oc_chat.registry.auth.secrets_store import block_from_secret, write_secrets
78from oc_chat.registry.backends.sqlite import RegistrySeed, SQLiteBackend
79from oc_chat.registry.cli.checkpoint import add_checkpoint_subcommands
80from oc_chat.registry.cli.db import add_db_subcommands
81from oc_chat.registry.cli.inspect import add_inspect_subcommands
82from oc_chat.registry.cli.migrate import add_migrate_subcommands
83from oc_chat.registry.cli.plan import add_plan_subcommands
84from oc_chat.registry.cli.queue import add_queue_subcommands
85from oc_chat.registry.cli.regenerate import add_regenerate_subcommand
86from oc_chat.registry.cli.roadmap import add_roadmap_subcommands
87from oc_chat.registry.cli.round import add_round_subcommands
88from oc_chat.registry.cli.sync import add_sync_subcommand
89from oc_chat.registry.discovery import discover_registry_location
90from oc_chat.registry.doctor import (
91 DoctorOptions,
92 format_doctor_report,
93 run_doctor,
94)
95
96_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
97
98
99def main(argv: list[str] | None = None) -> int:
100 parser = _build_parser()
101 args = parser.parse_args(argv)
102 handler = getattr(args, "handler", None)
103 if handler is None:
104 parser.print_help()
105 return 1
106 return int(handler(args))
107
108
109def _build_parser() -> argparse.ArgumentParser:
110 parser = argparse.ArgumentParser(
111 prog="python -m oc_chat.registry",
112 description="Orchestration registry operator commands.",
113 )
114 subcommands = parser.add_subparsers(dest="command")
115
116 init_parser = subcommands.add_parser(
117 "init",
118 help="Create and seed a registry SQLite database.",
119 )
120 init_parser.add_argument(
121 "registry",
122 nargs="?",
123 help="Registry SQLite path. Defaults to the discovery algorithm.",
124 )
125 init_parser.add_argument(
126 "--workspace-root",
127 help="Workspace root to record in the initial workspaces row.",
128 )
129 init_parser.add_argument(
130 "--operator-slot",
131 help="Initial human operator slot. Defaults to the local hostname.",
132 )
133 init_parser.add_argument(
134 "--quiet",
135 action="store_true",
136 help="Suppress success output.",
137 )
138 init_parser.set_defaults(handler=_cmd_init)
139
140 doctor_parser = subcommands.add_parser(
141 "doctor",
142 help="Run registry health checks and safe repairs.",
143 )
144 doctor_parser.add_argument(
145 "registry",
146 nargs="?",
147 help="Registry SQLite path. Defaults to the discovery algorithm.",
148 )
149 doctor_parser.add_argument(
150 "--fix-perms",
151 action="store_true",
152 help="Repair .option_c and agent_secrets.json modes where supported.",
153 )
154 doctor_parser.add_argument(
155 "--skip-perms",
156 action="store_true",
157 help="Skip local filesystem permission inspection.",
158 )
159 doctor_parser.add_argument(
160 "--no-sweep-temps",
161 action="store_true",
162 help="Do not remove stray .tmp-* files below .option_c/orchestration.",
163 )
164 doctor_parser.add_argument(
165 "--no-lock-sweep",
166 action="store_true",
167 help="Do not sweep expired volatile lock rows.",
168 )
169 doctor_parser.add_argument(
170 "--no-credential-check",
171 action="store_true",
172 help="Do not compare agent_secrets.json fingerprints to the registry.",
173 )
174 doctor_parser.add_argument(
175 "--json",
176 action="store_true",
177 help="Emit the structured doctor report as JSON.",
178 )
179 doctor_parser.add_argument(
180 "--resolve-snapshot-conflict",
181 action="store_true",
182 help="Resolve drifted rendered artifacts.",
183 )
184 doctor_parser.add_argument(
185 "--yes-trust-registry",
186 action="store_true",
187 help="Resolve snapshot drift by overwriting from the registry.",
188 )
189 doctor_parser.add_argument(
190 "--yes-trust-local",
191 action="store_true",
192 help="Resolve snapshot drift by keeping supported local edits.",
193 )
194 doctor_parser.set_defaults(handler=_cmd_doctor)
195
196 rotate_parser = subcommands.add_parser(
197 "rotate-secrets",
198 help="Start or finalize HMAC credential rotation.",
199 )
200 rotate_parser.add_argument(
201 "registry",
202 nargs="?",
203 help="Registry SQLite path. Defaults to the discovery algorithm.",
204 )
205 rotate_scope = rotate_parser.add_mutually_exclusive_group()
206 rotate_scope.add_argument("--slot", help="Slot to rotate.")
207 rotate_scope.add_argument(
208 "--all",
209 action="store_true",
210 help="Rotate every active slot.",
211 )
212 rotate_parser.add_argument(
213 "--grace-seconds",
214 type=int,
215 help="Rotation grace window override.",
216 )
217 rotate_parser.add_argument(
218 "--finalize",
219 action="store_true",
220 help="Finalize an in-flight rotation instead of starting one.",
221 )
222 rotate_parser.set_defaults(handler=_cmd_rotate_secrets)
223
224 handover_parser = subcommands.add_parser(
225 "handover",
226 help="Initiate or accept orchestrator handover.",
227 )
228 handover_parser.add_argument(
229 "registry",
230 nargs="?",
231 help="Registry SQLite path. Defaults to the discovery algorithm.",
232 )
233 handover_action = handover_parser.add_mutually_exclusive_group(required=True)
234 handover_action.add_argument("--to", help="Incoming orchestrator slot.")
235 handover_action.add_argument(
236 "--accept",
237 type=int,
238 metavar="HANDOVER_ID",
239 help="Accept a handover-start operation id.",
240 )
241 handover_parser.add_argument("--from-slot", help="Outgoing orchestrator slot.")
242 handover_parser.add_argument("--reason", default="operator handover")
243 handover_parser.set_defaults(handler=_cmd_handover)
244
245 auth_parser = subcommands.add_parser(
246 "auth",
247 help="Actor-auth inspection and bootstrap commands.",
248 )
249 add_auth_subcommands(auth_parser)
250
251 db_parser = subcommands.add_parser(
252 "db",
253 help="Database maintenance and inspection commands.",
254 )
255 add_db_subcommands(db_parser)
256
257 migrate_parser = subcommands.add_parser(
258 "migrate",
259 help="Legacy registry migration commands.",
260 )
261 add_migrate_subcommands(migrate_parser)
262
263 queue_parser = subcommands.add_parser(
264 "queue",
265 help="Registry-native queue commands.",
266 )
267 add_queue_subcommands(queue_parser)
268
269 plan_parser = subcommands.add_parser(
270 "plan",
271 help="Registry-native plan commands.",
272 )
273 add_plan_subcommands(plan_parser)
274
275 round_parser = subcommands.add_parser(
276 "round",
277 help="Registry-native round-close commands.",
278 )
279 add_round_subcommands(round_parser)
280
281 add_sync_subcommand(subcommands)
282 add_roadmap_subcommands(subcommands)
283 add_regenerate_subcommand(subcommands) # Plan 142 P1.4 (top-level, ยง13)
284
285 checkpoint_parser = subcommands.add_parser(
286 "checkpoint",
287 help="Export and import registry JSON checkpoints.",
288 )
289 add_checkpoint_subcommands(checkpoint_parser)
290
291 add_inspect_subcommands(subcommands)
292 return parser
293
294
295def _cmd_init(args: argparse.Namespace) -> int:
296 location = discover_registry_location(
297 registry_path=args.registry,
298 workspace_root=args.workspace_root,
299 )
300 raw_secret = secrets.token_bytes(32)
301 operator_slot = args.operator_slot or _default_operator_slot()
302 seed = RegistrySeed(
303 workspace_id=str(uuid.uuid4()),
304 registry_id=str(uuid.uuid4()),
305 workspace_root=str(location.workspace_root),
306 git_common_dir=str(location.git_common_dir),
307 registry_path=str(location.registry_path),
308 checkpoint_path=str(location.checkpoint_path),
309 operator_slot=operator_slot,
310 hmac_key_id=str(uuid.uuid4()),
311 secret_fingerprint=hashlib.sha256(raw_secret).hexdigest(),
312 app_version=resolve_version(),
313 )
314 backend = SQLiteBackend(location.registry_path)
315 backend.initialize(seed)
316 secret_block = block_from_secret(
317 slot=operator_slot,
318 phase="active",
319 hmac_key_id=seed.hmac_key_id,
320 raw_secret=raw_secret,
321 created_at=backend.clock.now_wall(),
322 )
323 write_secrets({operator_slot: {"active": secret_block}}, _secrets_path(location))
324 backend.startup_sweep()
325 if not args.quiet:
326 print(f"initialised registry: {location.registry_path}")
327 print(f"operator slot: {operator_slot}")
328 return 0
329
330
331def _cmd_doctor(args: argparse.Namespace) -> int:
332 location = discover_registry_location(registry_path=args.registry)
333 backend = SQLiteBackend(location.registry_path)
334 registry = Registry(backend)
335 report = run_doctor(
336 registry,
337 backend,
338 location.workspace_root,
340 fix_perms=args.fix_perms,
341 skip_perms=args.skip_perms,
342 sweep_temps=not args.no_sweep_temps,
343 lock_sweep=not args.no_lock_sweep,
344 credential_check=not args.no_credential_check,
345 resolve_snapshot_conflict=args.resolve_snapshot_conflict,
346 yes_trust_registry=args.yes_trust_registry,
347 yes_trust_local=args.yes_trust_local,
348 ),
349 )
350 if args.json:
351 print(json.dumps(report, indent=2, sort_keys=True))
352 else:
353 print(format_doctor_report(report))
354 return 0
355
356
357def add_auth_subcommands(parser: argparse.ArgumentParser) -> None:
358 """Register actor-auth subcommands."""
359
360 subcommands = parser.add_subparsers(dest="auth_command")
361
362 show = subcommands.add_parser("show", help="Show credential state.")
363 show.add_argument("registry", nargs="?")
364 show.add_argument("--slot")
365 show.set_defaults(handler=_cmd_auth_show)
366
367 verify = subcommands.add_parser("verify", help="Verify recent auth metadata.")
368 verify.add_argument("registry", nargs="?")
369 verify.add_argument("--slot")
370 verify.add_argument("--all", action="store_true")
371 verify.set_defaults(handler=_cmd_auth_verify)
372
373 export = subcommands.add_parser("export-secrets", help="Export local secrets.")
374 export.add_argument("registry", nargs="?")
375 export.add_argument("--slot")
376 export.add_argument("--out", required=True)
377 export.add_argument("--encrypt-command")
378 export.set_defaults(handler=_cmd_auth_export)
379
380 bootstrap = subcommands.add_parser(
381 "bootstrap-machine",
382 help="Install local secrets from a bundle.",
383 )
384 bootstrap.add_argument("registry", nargs="?")
385 bootstrap.add_argument("--secrets", required=True)
386 bootstrap.add_argument("--decrypt-command")
387 bootstrap.set_defaults(handler=_cmd_auth_bootstrap)
388
389
390def _cmd_rotate_secrets(args: argparse.Namespace) -> int:
391 location = discover_registry_location(registry_path=args.registry)
392 backend = SQLiteBackend(location.registry_path)
393 registry = Registry(backend)
394 actor_slot = backend.active_operator_slot()
395 if actor_slot is None:
396 print("no active operator slot", file=sys.stderr)
397 return 1
398 slots = _credential_slots(backend) if args.all else [args.slot or actor_slot]
399 failures = 0
400 for slot in slots:
401 try:
402 if args.finalize:
403 result = finalize_rotation(
404 registry,
405 slot=slot,
406 actor_slot=actor_slot,
407 secrets_path=_secrets_path(location),
408 )
409 print(
410 f"finalized {slot}: {result.old_key_id[:8]} -> "
411 f"{result.new_key_id[:8]}"
412 )
413 else:
414 result = rotate_secret(
415 registry,
416 slot=slot,
417 actor_slot=actor_slot,
418 grace_seconds=args.grace_seconds,
419 secrets_path=_secrets_path(location),
420 )
421 print(
422 f"rotating {slot}: {result.old_key_id[:8]} -> "
423 f"{result.new_key_id[:8]}"
424 )
425 except Exception as exc:
426 failures += 1
427 print(f"{slot}: {exc}", file=sys.stderr)
428 if failures and failures != len(slots):
429 return 2
430 return 1 if failures else 0
431
432
433def _cmd_handover(args: argparse.Namespace) -> int:
434 location = discover_registry_location(registry_path=args.registry)
435 backend = SQLiteBackend(location.registry_path)
436 registry = Registry(backend)
437 if args.accept is not None:
438 actor_slot = args.from_slot or _current_orchestrator_or_operator(backend)
439 row = accept_handover(
440 registry,
441 handover_id=args.accept,
442 actor_slot=actor_slot,
443 actor_type=_actor_type_for_slot(backend, actor_slot),
444 )
445 print(f"accepted handover: operation {row.id}")
446 return 0
447 from_slot = args.from_slot or _current_orchestrator_or_operator(backend)
448 row = initiate_handover(
449 registry,
450 from_slot=from_slot,
451 to_slot=args.to,
452 reason=args.reason,
453 actor_slot=from_slot,
454 actor_type=_actor_type_for_slot(backend, from_slot),
455 )
456 print(f"started handover: operation {row.id}")
457 return 0
458
459
460def _cmd_auth_show(args: argparse.Namespace) -> int:
461 location = discover_registry_location(registry_path=args.registry)
462 backend = SQLiteBackend(location.registry_path)
463 for row in _credential_rows(backend, slot=args.slot):
464 fingerprint = str(row["secret_fingerprint"])
465 print(
466 f"{row['slot']} {row['rotation_phase']} "
467 f"key={str(row['hmac_key_id'])[:8]} "
468 f"fingerprint={fingerprint[:8]} "
469 f"expires_at={row['expires_at'] or '-'} "
470 f"revoked_at={row['revoked_at'] or '-'}"
471 )
472 return 0
473
474
475def _cmd_auth_verify(args: argparse.Namespace) -> int:
476 location = discover_registry_location(registry_path=args.registry)
477 backend = SQLiteBackend(location.registry_path)
478 rows = _operation_auth_rows(backend, slot=args.slot)
479 failures = [
480 row for row in rows
481 if row["actor_slot"] != "system" and row["hmac_key_id"] is None
482 ]
483 print(f"checked={len(rows)} failures={len(failures)}")
484 return 1 if failures else 0
485
486
487def _cmd_auth_export(args: argparse.Namespace) -> int:
488 location = discover_registry_location(registry_path=args.registry)
489 backend = SQLiteBackend(location.registry_path)
490 registry = Registry(backend)
491 result = export_bundle(
492 registry,
493 out=args.out,
494 secrets_path=_secrets_path(location),
495 slot=args.slot,
496 encrypt_command=args.encrypt_command,
497 )
498 print(f"exported {result.slot_count} slot(s): {result.path}")
499 return 0
500
501
502def _cmd_auth_bootstrap(args: argparse.Namespace) -> int:
503 location = discover_registry_location(registry_path=args.registry)
504 backend = SQLiteBackend(location.registry_path)
505 registry = Registry(backend)
506 result = bootstrap_machine(
507 registry,
508 bundle_path=args.secrets,
509 secrets_path=_secrets_path(location),
510 decrypt_command=args.decrypt_command,
511 )
512 print(f"installed {result.slot_count} slot(s): {result.path}")
513 return 0
514
515
517 hostname = socket.gethostname().lower()
518 safe = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in hostname)
519 safe = "-".join(part for part in safe.split("-") if part) or "local"
520 return f"human-operator-{safe[:48]}"
521
522
523def _secrets_path(location: object) -> Path:
524 return Path(location.workspace_root) / ".option_c" / "agent_secrets.json"
525
526
527def _credential_slots(backend: SQLiteBackend) -> list[str]:
528 with backend.connect() as conn:
529 return [
530 str(row["slot"])
531 for row in conn.execute(
532 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help, SQL projections, and JSON field names are not secrets"
533 "SELECT DISTINCT slot FROM agent_credentials "
534 "WHERE revoked_at IS NULL ORDER BY slot"
535 ).fetchall()
536 ]
537
538
540 backend: SQLiteBackend,
541 *,
542 slot: str | None = None,
543) -> list[object]:
544 with backend.connect() as conn:
545 if slot is None:
546 return conn.execute(
547 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help, SQL projections, and JSON field names are not secrets"
548 "SELECT hmac_key_id, slot, secret_fingerprint, rotation_phase, "
549 "expires_at, revoked_at FROM agent_credentials "
550 "ORDER BY slot, rotation_phase"
551 ).fetchall()
552 return conn.execute(
553 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help, SQL projections, and JSON field names are not secrets"
554 "SELECT hmac_key_id, slot, secret_fingerprint, rotation_phase, "
555 "expires_at, revoked_at FROM agent_credentials "
556 "WHERE slot = ? ORDER BY rotation_phase",
557 (slot,),
558 ).fetchall()
559
560
562 backend: SQLiteBackend,
563 *,
564 slot: str | None = None,
565) -> list[object]:
566 with backend.connect() as conn:
567 if slot is None:
568 return conn.execute(
569 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help, SQL projections, and JSON field names are not secrets"
570 "SELECT actor_slot, hmac_key_id FROM operation_log ORDER BY id"
571 ).fetchall()
572 return conn.execute(
573 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI help, SQL projections, and JSON field names are not secrets"
574 "SELECT actor_slot, hmac_key_id FROM operation_log "
575 "WHERE actor_slot = ? ORDER BY id",
576 (slot,),
577 ).fetchall()
578
579
580def _current_orchestrator_or_operator(backend: SQLiteBackend) -> str:
581 with backend.connect() as conn:
582 row = conn.execute(
583 "SELECT orchestrator_slot FROM orchestration_state WHERE id = 1"
584 ).fetchone()
585 if row is not None and row["orchestrator_slot"]:
586 return str(row["orchestrator_slot"])
587 operator_slot = backend.active_operator_slot()
588 if operator_slot is None:
589 raise ValueError("no active orchestrator or operator slot")
590 return operator_slot
591
592
593def _actor_type_for_slot(backend: SQLiteBackend, slot: str) -> str:
594 slot_kind = backend.actor_slot_kind(slot)
595 return {
596 "operator": "human_operator",
597 "orchestrator": "orchestrator_agent",
598 "worker": "worker_agent",
599 }[slot_kind]
600
601
602if __name__ == "__main__":
603 sys.exit(main())
int _cmd_auth_bootstrap(argparse.Namespace args)
Definition agent.py:502
None add_auth_subcommands(argparse.ArgumentParser parser)
Definition agent.py:357
int _cmd_auth_verify(argparse.Namespace args)
Definition agent.py:475
list[str] _credential_slots(SQLiteBackend backend)
Definition agent.py:527
Path _secrets_path(object location)
Definition agent.py:523
int _cmd_auth_export(argparse.Namespace args)
Definition agent.py:487
int _cmd_rotate_secrets(argparse.Namespace args)
Definition agent.py:390
int _cmd_handover(argparse.Namespace args)
Definition agent.py:433
list[object] _operation_auth_rows(SQLiteBackend backend, *, str|None slot=None)
Definition agent.py:565
argparse.ArgumentParser _build_parser()
Definition agent.py:109
str _current_orchestrator_or_operator(SQLiteBackend backend)
Definition agent.py:580
list[object] _credential_rows(SQLiteBackend backend, *, str|None slot=None)
Definition agent.py:543
str _actor_type_for_slot(SQLiteBackend backend, str slot)
Definition agent.py:593
int _cmd_init(argparse.Namespace args)
Definition agent.py:295
int _cmd_auth_show(argparse.Namespace args)
Definition agent.py:460
int _cmd_doctor(argparse.Namespace args)
Definition agent.py:331