conduct-cli 0.14.7__tar.gz → 0.14.8__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.
Files changed (61) hide show
  1. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/PKG-INFO +1 -1
  2. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/pyproject.toml +1 -1
  3. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/pretooluse.py +11 -1
  4. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/main.py +265 -0
  5. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  7. conduct_cli-0.14.8/tests/test_gateway_config_import_export.py +293 -0
  8. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/LICENSE +0 -0
  9. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/README.md +0 -0
  10. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/setup.cfg +0 -0
  11. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/setup.py +0 -0
  12. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/guard.py +0 -0
  15. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/guard_policy.md +0 -0
  16. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/__init__.py +0 -0
  17. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/base.py +0 -0
  18. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/posttooluse.py +0 -0
  19. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/precompact.py +0 -0
  20. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_parser.py +0 -0
  21. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_report_push.py +0 -0
  22. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/session_start.py +0 -0
  23. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/hooks/stop.py +0 -0
  24. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/log_util.py +0 -0
  25. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/memory.py +0 -0
  26. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/paxel.py +0 -0
  27. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli/tool_groups.py +0 -0
  28. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_advisory_and_hook.py +0 -0
  33. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_api_client.py +0 -0
  34. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_bash_operator_signature.py +0 -0
  35. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_bash_scan_target.py +0 -0
  36. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_blast_radius.py +0 -0
  37. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_cli_matrix.py +0 -0
  38. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_cmd_run.py +0 -0
  39. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_command_word_matcher.py +0 -0
  40. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_drain_daemon_health.py +0 -0
  41. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_approvals.py +0 -0
  42. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_policy.py +0 -0
  43. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_savings.py +0 -0
  44. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_guard_status.py +0 -0
  45. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_dispatch.py +0 -0
  46. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_journal.py +0 -0
  47. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_hook_receipt.py +0 -0
  48. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_install_all.py +0 -0
  49. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_instruction_files.py +0 -0
  50. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_journal_drain.py +0 -0
  51. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_log_util.py +0 -0
  52. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_pretooluse_approval_e2e.py +0 -0
  53. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_pretooluse_check_policy.py +0 -0
  54. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_proxy_coverage.py +0 -0
  55. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_proxy_env.py +0 -0
  56. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_report_tool_coverage.py +0 -0
  57. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_session_start.py +0 -0
  58. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_signed_policy.py +0 -0
  59. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_switch.py +0 -0
  60. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_sync_credential_isolation.py +0 -0
  61. {conduct_cli-0.14.7 → conduct_cli-0.14.8}/tests/test_token_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.14.7
3
+ Version: 0.14.8
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.14.7"
7
+ version = "0.14.8"
8
8
  description = "Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -8,6 +8,7 @@ import shlex
8
8
  import subprocess
9
9
  import sys
10
10
  import time
11
+ import urllib.parse
11
12
  import urllib.request
12
13
  from pathlib import Path
13
14
 
@@ -230,9 +231,18 @@ def _fetch_budget_status():
230
231
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
231
232
  if not workspace_id:
232
233
  return False, "unconfigured"
234
+ # ai_tool lets the server scope enforcement per-tool so an overspend in
235
+ # one client (e.g. codex-desktop) doesn't block others (e.g. claude-code).
236
+ try:
237
+ from conduct_cli.hooks.base import detect_ai_tool
238
+ ai_tool = detect_ai_tool() or ""
239
+ except Exception:
240
+ ai_tool = ""
233
241
  url = f"{api_url}/guard/spend/budget-check?workspace_id={workspace_id}"
234
242
  if clerk_user_id:
235
- url += f"&clerk_user_id={clerk_user_id}"
243
+ url += f"&clerk_user_id={urllib.parse.quote(clerk_user_id, safe='')}"
244
+ if ai_tool:
245
+ url += f"&ai_tool={urllib.parse.quote(ai_tool, safe='')}"
236
246
  try:
237
247
  with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
238
248
  data = json.loads(resp.read())
@@ -2885,6 +2885,215 @@ def cmd_import_cedar(args):
2885
2885
  print(f"\nThis was a preview. Re-run with --install to create the pack in your workspace.")
2886
2886
 
2887
2887
 
2888
+ def cmd_import_gateway_config(args):
2889
+ """conduct import --gateway-config <file.json> [--name NEW_NAME]
2890
+
2891
+ Import a Gateway Profile v2 from a portable JSON file. Server strips
2892
+ every ``credential_ref`` on import — the admin then picks vault +
2893
+ credential handle per target in the UI (Save gate blocks until
2894
+ filled).
2895
+
2896
+ The JSON shape is the ``working_copy`` dict — same shape
2897
+ ``conduct export --gateway-config`` emits and the same shape the
2898
+ editor's Save call posts. Round-trip is safe: export → import
2899
+ produces a draft the admin can complete in one editor session.
2900
+
2901
+ Exit codes: 0 on success, 1 on any failure (auth, missing file,
2902
+ invalid JSON, backend rejection). Error output goes to stderr so
2903
+ callers can pipe stdout without noise.
2904
+ """
2905
+ server, workspace, token = _require_auth(args)
2906
+ if not token:
2907
+ print(f"{RED}Not authenticated. Run `conduct login` first.{RESET}", file=sys.stderr)
2908
+ sys.exit(1)
2909
+
2910
+ path = Path(args.file)
2911
+ if not path.exists():
2912
+ print(f"{RED}File not found: {path}{RESET}", file=sys.stderr)
2913
+ sys.exit(1)
2914
+
2915
+ try:
2916
+ working_copy = json.loads(path.read_text())
2917
+ except json.JSONDecodeError as e:
2918
+ print(f"{RED}Invalid JSON in {path}: {e}{RESET}", file=sys.stderr)
2919
+ sys.exit(1)
2920
+
2921
+ if not isinstance(working_copy, dict):
2922
+ print(
2923
+ f"{RED}Expected a JSON object at the top level (the "
2924
+ f"working_copy dict); got {type(working_copy).__name__}.{RESET}",
2925
+ file=sys.stderr,
2926
+ )
2927
+ sys.exit(1)
2928
+
2929
+ body_dict = {"working_copy": working_copy}
2930
+ if getattr(args, "name", None):
2931
+ body_dict["name_override"] = args.name
2932
+
2933
+ req = urllib.request.Request(
2934
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2/import",
2935
+ data=json.dumps(body_dict).encode("utf-8"),
2936
+ headers={
2937
+ "Content-Type": "application/json",
2938
+ "Authorization": f"Bearer {token}",
2939
+ "X-Workspace-Id": workspace or "",
2940
+ },
2941
+ method="POST",
2942
+ )
2943
+
2944
+ try:
2945
+ with urllib.request.urlopen(req, timeout=30) as r:
2946
+ result = json.loads(r.read())
2947
+ except urllib.error.HTTPError as e:
2948
+ body_text = e.read().decode(errors="replace")
2949
+ # Backend returns structured errors (PR #2033) — parse them
2950
+ # into a readable per-line summary when possible.
2951
+ try:
2952
+ payload = json.loads(body_text)
2953
+ detail = payload.get("detail")
2954
+ if isinstance(detail, dict) and isinstance(detail.get("errors"), list):
2955
+ lines = [detail.get("summary", "import failed")]
2956
+ for err in detail["errors"][:5]:
2957
+ where = err.get("path") or "(profile)"
2958
+ lines.append(f" {where}: {err.get('message', 'invalid')}")
2959
+ if len(detail["errors"]) > 5:
2960
+ lines.append(f" +{len(detail['errors']) - 5} more")
2961
+ detail_text = "\n".join(lines)
2962
+ else:
2963
+ detail_text = str(detail or body_text)[:600]
2964
+ except Exception:
2965
+ detail_text = body_text[:600]
2966
+ print(f"{RED}Import failed ({e.code}):{RESET}\n{detail_text}", file=sys.stderr)
2967
+ sys.exit(1)
2968
+ except Exception as e:
2969
+ print(f"{RED}Request failed: {e}{RESET}", file=sys.stderr)
2970
+ sys.exit(1)
2971
+
2972
+ profile = result.get("profile") or {}
2973
+ gaps = result.get("credential_gaps") or []
2974
+ next_url = result.get("next_url") or ""
2975
+
2976
+ print(f"{BOLD}Gateway profile imported{RESET}")
2977
+ print(f" {GREEN}Name:{RESET} {profile.get('name', '?')}")
2978
+ print(f" {GREEN}Profile ID:{RESET} {profile.get('id', '?')}")
2979
+ print(f" {GREEN}Cond code:{RESET} {profile.get('cond_code', '?')}")
2980
+
2981
+ if gaps:
2982
+ print(f"\n{YELLOW}{len(gaps)} target(s) need credentials before publish:{RESET}")
2983
+ for g in gaps:
2984
+ print(
2985
+ f" #{g.get('target_index', '?') + 1 if isinstance(g.get('target_index'), int) else '?'} "
2986
+ f"{g.get('target_id', '?')} ({g.get('transport', '?')}): "
2987
+ f"{g.get('reason', 'pick a vault')}"
2988
+ )
2989
+
2990
+ if next_url:
2991
+ print(f"\n{BLUE}Finish setup:{RESET} {next_url}")
2992
+
2993
+
2994
+ def cmd_export_gateway_config(args):
2995
+ """conduct export --gateway-config <profile-id-or-cond-code> [--out FILE]
2996
+
2997
+ Pull a Gateway Profile v2 as portable JSON. Server strips every
2998
+ ``credential_ref`` on export (safe to commit / share). Writes to
2999
+ stdout by default; ``--out FILE`` writes to a file instead.
3000
+
3001
+ ``target`` is one of:
3002
+ * a profile UUID (e.g. ``123e4567-e89b-12d3-a456-426614174000``),
3003
+ * a bare cond_code (e.g. ``abc12345``), OR
3004
+ * the full ``cond-<code>-<alias>`` string admins see in the
3005
+ "How to use" panel (e.g. ``cond-abc12345-my-profile``).
3006
+
3007
+ Bare model aliases (e.g. ``claude-sonnet-4-6``) are NOT resolved —
3008
+ they map to targets inside a profile, not to profiles themselves.
3009
+
3010
+ Exit codes: 0 on success, 1 on any failure (auth, unresolved
3011
+ target, backend error). Errors go to stderr so ``--out`` piping
3012
+ stays clean.
3013
+ """
3014
+ server, workspace, token = _require_auth(args)
3015
+ if not token:
3016
+ print(f"{RED}Not authenticated. Run `conduct login` first.{RESET}", file=sys.stderr)
3017
+ sys.exit(1)
3018
+
3019
+ profile_id = _resolve_gateway_profile_id(server, workspace, token, args.target)
3020
+ if not profile_id:
3021
+ # _resolve_gateway_profile_id already printed the specific
3022
+ # error to stderr — just exit nonzero here.
3023
+ sys.exit(1)
3024
+
3025
+ req = urllib.request.Request(
3026
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2/{profile_id}/export",
3027
+ headers={
3028
+ "Authorization": f"Bearer {token}",
3029
+ "X-Workspace-Id": workspace or "",
3030
+ },
3031
+ method="GET",
3032
+ )
3033
+ try:
3034
+ with urllib.request.urlopen(req, timeout=30) as r:
3035
+ snapshot = json.loads(r.read())
3036
+ except urllib.error.HTTPError as e:
3037
+ body_text = e.read().decode(errors="replace")
3038
+ print(f"{RED}Export failed ({e.code}): {body_text[:400]}{RESET}", file=sys.stderr)
3039
+ sys.exit(1)
3040
+ except Exception as e:
3041
+ print(f"{RED}Request failed: {e}{RESET}", file=sys.stderr)
3042
+ sys.exit(1)
3043
+
3044
+ out_json = json.dumps(snapshot, indent=2)
3045
+ out_path = getattr(args, "out", None)
3046
+ if out_path:
3047
+ Path(out_path).write_text(out_json + "\n")
3048
+ print(f"{GREEN}Wrote:{RESET} {out_path}", file=sys.stderr)
3049
+ else:
3050
+ print(out_json)
3051
+
3052
+
3053
+ def _resolve_gateway_profile_id(server: str, workspace: str, token: str, target: str) -> str | None:
3054
+ """Accept either a UUID or a cond_code. Returns the UUID or prints
3055
+ an error + returns None."""
3056
+ # UUID shape: 8-4-4-4-12 hex.
3057
+ import re as _re
3058
+ if _re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", target, _re.IGNORECASE):
3059
+ return target
3060
+
3061
+ # cond_code lookup — fetch the list, match by cond_code.
3062
+ req = urllib.request.Request(
3063
+ f"{server}/workspaces/{workspace}/gateway-profiles-v2",
3064
+ headers={
3065
+ "Authorization": f"Bearer {token}",
3066
+ "X-Workspace-Id": workspace or "",
3067
+ },
3068
+ method="GET",
3069
+ )
3070
+ try:
3071
+ with urllib.request.urlopen(req, timeout=30) as r:
3072
+ profiles = json.loads(r.read())
3073
+ except Exception as e:
3074
+ print(
3075
+ f"{RED}Could not list profiles to resolve {target!r}: {e}{RESET}",
3076
+ file=sys.stderr,
3077
+ )
3078
+ return None
3079
+
3080
+ for p in profiles or []:
3081
+ if p.get("cond_code") == target:
3082
+ return p.get("id")
3083
+ # Also accept "cond-<code>-<alias>" for convenience.
3084
+ alias = p.get("model_alias") or ""
3085
+ full = f"cond-{p.get('cond_code', '')}-{alias}"
3086
+ if target == full:
3087
+ return p.get("id")
3088
+
3089
+ print(
3090
+ f"{RED}No profile matched {target!r}. Pass a profile UUID, "
3091
+ f"a cond_code, or the full cond-<code>-<alias> identifier.{RESET}",
3092
+ file=sys.stderr,
3093
+ )
3094
+ return None
3095
+
3096
+
2888
3097
  def cmd_skill(args):
2889
3098
  """conduct skill list | install <slug> | uninstall <slug>"""
2890
3099
  skill_command = getattr(args, "skill_command", None)
@@ -3102,6 +3311,46 @@ def main():
3102
3311
  ic_p.add_argument("--install", action="store_true", help="Install immediately (default is preview only)")
3103
3312
  ic_p.add_argument("--yes", action="store_true", help="Skip confirmation prompt when installing")
3104
3313
 
3314
+ # conduct import --gateway-config <file>
3315
+ # conduct export --gateway-config <profile-id-or-cond-code> [--out FILE]
3316
+ #
3317
+ # Both commands share ``--gateway-config`` as the surface. Adding
3318
+ # future importable resource types (e.g. --guard-pack, --persona)
3319
+ # is a matter of extending these two parsers, keeping the noun
3320
+ # namespace ``conduct import`` / ``conduct export`` uncluttered.
3321
+ imp_p = sub.add_parser(
3322
+ "import",
3323
+ help="Import a resource from JSON (currently: Gateway Profile v2)",
3324
+ )
3325
+ imp_p.add_argument(
3326
+ "--gateway-config",
3327
+ metavar="FILE",
3328
+ help="Path to a Gateway Profile v2 JSON file (working_copy shape).",
3329
+ )
3330
+ imp_p.add_argument(
3331
+ "--name",
3332
+ metavar="NAME",
3333
+ default=None,
3334
+ help="Override the profile name in the JSON. Useful when the "
3335
+ "source name is already taken in the target workspace.",
3336
+ )
3337
+
3338
+ exp_p = sub.add_parser(
3339
+ "export",
3340
+ help="Export a resource as portable JSON (currently: Gateway Profile v2)",
3341
+ )
3342
+ exp_p.add_argument(
3343
+ "--gateway-config",
3344
+ metavar="PROFILE",
3345
+ help="Profile UUID, cond_code (8 chars), or full cond-<code>-<alias> identifier.",
3346
+ )
3347
+ exp_p.add_argument(
3348
+ "--out",
3349
+ metavar="FILE",
3350
+ default=None,
3351
+ help="Write JSON to FILE (default: stdout).",
3352
+ )
3353
+
3105
3354
  # conduct sync
3106
3355
  sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
3107
3356
 
@@ -3205,6 +3454,22 @@ def main():
3205
3454
  cmd_skill(args)
3206
3455
  elif args.command == "import-cedar":
3207
3456
  cmd_import_cedar(args)
3457
+ elif args.command == "import":
3458
+ # Only Gateway Profile v2 import today. Other imports (Guard
3459
+ # skill packs, personas, etc.) already have their own commands.
3460
+ if getattr(args, "gateway_config", None):
3461
+ args.file = args.gateway_config
3462
+ cmd_import_gateway_config(args)
3463
+ else:
3464
+ print(f"{RED}Usage: conduct import --gateway-config <file.json>{RESET}", file=sys.stderr)
3465
+ sys.exit(1)
3466
+ elif args.command == "export":
3467
+ if getattr(args, "gateway_config", None):
3468
+ args.target = args.gateway_config
3469
+ cmd_export_gateway_config(args)
3470
+ else:
3471
+ print(f"{RED}Usage: conduct export --gateway-config <profile-id-or-cond-code>{RESET}", file=sys.stderr)
3472
+ sys.exit(1)
3208
3473
  elif args.command == "sync":
3209
3474
  cmd_sync(args)
3210
3475
  elif args.command == "verify":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.14.7
3
+ Version: 0.14.8
4
4
  Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: Apache-2.0
@@ -35,6 +35,7 @@ tests/test_cli_matrix.py
35
35
  tests/test_cmd_run.py
36
36
  tests/test_command_word_matcher.py
37
37
  tests/test_drain_daemon_health.py
38
+ tests/test_gateway_config_import_export.py
38
39
  tests/test_guard_approvals.py
39
40
  tests/test_guard_policy.py
40
41
  tests/test_guard_savings.py
@@ -0,0 +1,293 @@
1
+ """Tests for gateway-config import/export CLI commands.
2
+
3
+ Scope: argparse wiring + happy path + error paths. HTTP layer stubbed
4
+ via ``urllib.request.urlopen`` so no real API calls.
5
+
6
+ Invariants locked:
7
+ - Import posts to the design-locked URL.
8
+ - ``--name`` translates to ``name_override``.
9
+ - Non-existent file → readable error, no HTTP call.
10
+ - Non-JSON file → readable error, no HTTP call.
11
+ - Structured backend errors (PR #2033) render per-line.
12
+ - Export resolves cond_code via the list endpoint first.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import json
18
+ from unittest.mock import MagicMock
19
+
20
+ import pytest
21
+
22
+ from conduct_cli import main as cli
23
+
24
+
25
+ UUID = "22222222-2222-2222-2222-222222222222"
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _stub_auth(monkeypatch):
30
+ monkeypatch.setattr(
31
+ cli, "_require_auth",
32
+ lambda args: ("https://api.example.com", "ws-1", "guard-mt-fake"),
33
+ )
34
+
35
+
36
+ def _mk_response(payload, status=200):
37
+ class _CM:
38
+ def __enter__(self):
39
+ fake = MagicMock()
40
+ fake.read.return_value = json.dumps(payload).encode("utf-8")
41
+ fake.status = status
42
+ return fake
43
+ def __exit__(self, *a): return False
44
+ return _CM()
45
+
46
+
47
+ # ─── import ──────────────────────────────────────────────────────────
48
+
49
+
50
+ def test_import_posts_to_correct_url(tmp_path, monkeypatch):
51
+ src = tmp_path / "profile.json"
52
+ src.write_text(json.dumps({"name": "x", "targets": []}))
53
+ captured = {}
54
+ def _fake(req, timeout=None):
55
+ captured["url"] = req.full_url
56
+ captured["method"] = req.get_method()
57
+ return _mk_response({
58
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
59
+ "credential_gaps": [], "next_url": "",
60
+ })
61
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
62
+
63
+ args = MagicMock(); args.file = str(src); args.name = None
64
+ cli.cmd_import_gateway_config(args)
65
+
66
+ assert captured["url"].endswith("/workspaces/ws-1/gateway-profiles-v2/import")
67
+ assert captured["method"] == "POST"
68
+
69
+
70
+ def test_import_body_wraps_json_in_working_copy(tmp_path, monkeypatch):
71
+ src = tmp_path / "p.json"
72
+ payload = {"name": "x", "targets": [{"id": "a"}]}
73
+ src.write_text(json.dumps(payload))
74
+ captured = {}
75
+ def _fake(req, timeout=None):
76
+ captured["body"] = json.loads(req.data.decode("utf-8"))
77
+ return _mk_response({
78
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
79
+ "credential_gaps": [], "next_url": "",
80
+ })
81
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
82
+
83
+ args = MagicMock(); args.file = str(src); args.name = None
84
+ cli.cmd_import_gateway_config(args)
85
+
86
+ assert captured["body"]["working_copy"] == payload
87
+
88
+
89
+ def test_import_name_flag_becomes_override(tmp_path, monkeypatch):
90
+ src = tmp_path / "p.json"
91
+ src.write_text(json.dumps({"name": "orig"}))
92
+ captured = {}
93
+ def _fake(req, timeout=None):
94
+ captured["body"] = json.loads(req.data.decode("utf-8"))
95
+ return _mk_response({
96
+ "profile": {"id": UUID, "name": "renamed", "cond_code": "aa"},
97
+ "credential_gaps": [], "next_url": "",
98
+ })
99
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
100
+
101
+ args = MagicMock(); args.file = str(src); args.name = "renamed"
102
+ cli.cmd_import_gateway_config(args)
103
+
104
+ assert captured["body"].get("name_override") == "renamed"
105
+
106
+
107
+ def test_import_missing_file_no_http(tmp_path, monkeypatch, capsys):
108
+ called = []
109
+ monkeypatch.setattr(
110
+ cli.urllib.request, "urlopen",
111
+ lambda req, timeout=None: (called.append(True), _mk_response({}))[1],
112
+ )
113
+ args = MagicMock()
114
+ args.file = str(tmp_path / "nope.json"); args.name = None
115
+ # R5: missing file exits nonzero and prints the error to stderr,
116
+ # not stdout — scripts piping stdout must not see this noise.
117
+ with pytest.raises(SystemExit) as exc_info:
118
+ cli.cmd_import_gateway_config(args)
119
+ assert exc_info.value.code == 1
120
+ assert called == []
121
+ err = capsys.readouterr()
122
+ assert "File not found" in err.err
123
+ assert "File not found" not in err.out
124
+
125
+
126
+ def test_import_invalid_json_no_http(tmp_path, monkeypatch, capsys):
127
+ bad = tmp_path / "bad.json"; bad.write_text("{ not valid")
128
+ called = []
129
+ monkeypatch.setattr(
130
+ cli.urllib.request, "urlopen",
131
+ lambda req, timeout=None: (called.append(True), _mk_response({}))[1],
132
+ )
133
+ args = MagicMock(); args.file = str(bad); args.name = None
134
+ # R5: invalid JSON exits nonzero and prints to stderr.
135
+ with pytest.raises(SystemExit) as exc_info:
136
+ cli.cmd_import_gateway_config(args)
137
+ assert exc_info.value.code == 1
138
+ assert called == []
139
+ err = capsys.readouterr()
140
+ assert "Invalid JSON" in err.err
141
+ assert "Invalid JSON" not in err.out
142
+
143
+
144
+ def test_import_renders_structured_error_per_line(tmp_path, monkeypatch, capsys):
145
+ import urllib.error
146
+ src = tmp_path / "p.json"
147
+ src.write_text(json.dumps({"name": "x"}))
148
+ payload = {
149
+ "detail": {
150
+ "summary": "schema invalid",
151
+ "errors": [
152
+ {"path": "targets.0.credential_ref",
153
+ "message": "must not be empty",
154
+ "target_index": 0, "type": "value_error"},
155
+ {"path": "accepts", "message": "field required",
156
+ "target_index": None, "type": "missing"},
157
+ ],
158
+ }
159
+ }
160
+ err = urllib.error.HTTPError(
161
+ "http://x", 400, "Bad Request", {},
162
+ io.BytesIO(json.dumps(payload).encode("utf-8")),
163
+ )
164
+ monkeypatch.setattr(
165
+ cli.urllib.request, "urlopen",
166
+ lambda req, timeout=None: (_ for _ in ()).throw(err),
167
+ )
168
+ args = MagicMock(); args.file = str(src); args.name = None
169
+ # R5: backend rejection exits nonzero and lands on stderr — but
170
+ # the structured per-line rendering still happens so CI logs are
171
+ # readable.
172
+ with pytest.raises(SystemExit) as exc_info:
173
+ cli.cmd_import_gateway_config(args)
174
+ assert exc_info.value.code == 1
175
+ stderr = capsys.readouterr().err
176
+ assert "schema invalid" in stderr
177
+ assert "targets.0.credential_ref" in stderr
178
+ assert "must not be empty" in stderr
179
+
180
+
181
+ def test_import_prints_credential_gaps(tmp_path, monkeypatch, capsys):
182
+ src = tmp_path / "p.json"; src.write_text(json.dumps({"name": "x"}))
183
+ monkeypatch.setattr(cli.urllib.request, "urlopen", lambda req, timeout=None: _mk_response({
184
+ "profile": {"id": UUID, "name": "x", "cond_code": "aa"},
185
+ "credential_gaps": [
186
+ {"target_index": 0, "target_id": "primary", "transport": "native_http",
187
+ "reason": "credential_ref stripped on import"},
188
+ {"target_index": 1, "target_id": "fallback", "transport": "native_http",
189
+ "reason": "credential_ref stripped on import"},
190
+ ],
191
+ "next_url": "https://api.example.com/proxy/gateway-profiles",
192
+ }))
193
+ args = MagicMock(); args.file = str(src); args.name = None
194
+ cli.cmd_import_gateway_config(args)
195
+ out = capsys.readouterr().out
196
+ assert "2 target(s) need credentials" in out
197
+ assert "primary" in out and "fallback" in out
198
+ assert "/proxy/gateway-profiles" in out
199
+
200
+
201
+ # ─── export ──────────────────────────────────────────────────────────
202
+
203
+
204
+ def test_export_by_uuid_direct(monkeypatch, capsys):
205
+ calls = []
206
+ def _fake(req, timeout=None):
207
+ calls.append(req.full_url)
208
+ return _mk_response({"name": "claude", "targets": []})
209
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
210
+ args = MagicMock(); args.target = UUID; args.out = None
211
+ cli.cmd_export_gateway_config(args)
212
+ assert calls == [f"https://api.example.com/workspaces/ws-1/gateway-profiles-v2/{UUID}/export"]
213
+ assert '"name": "claude"' in capsys.readouterr().out
214
+
215
+
216
+ def test_export_by_cond_code_resolves_via_list(monkeypatch, capsys):
217
+ calls = []
218
+ def _fake(req, timeout=None):
219
+ calls.append(req.full_url)
220
+ if req.full_url.endswith("/gateway-profiles-v2"):
221
+ return _mk_response([
222
+ {"id": UUID, "cond_code": "abc12345", "model_alias": "claude"},
223
+ ])
224
+ return _mk_response({"name": "resolved", "targets": []})
225
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
226
+ args = MagicMock(); args.target = "abc12345"; args.out = None
227
+ cli.cmd_export_gateway_config(args)
228
+ assert len(calls) == 2
229
+ assert calls[0].endswith("/gateway-profiles-v2")
230
+ assert calls[1].endswith(f"/gateway-profiles-v2/{UUID}/export")
231
+
232
+
233
+ def test_export_unknown_cond_code_no_export_call(monkeypatch, capsys):
234
+ def _fake(req, timeout=None):
235
+ return _mk_response([{"id": UUID, "cond_code": "other"}])
236
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
237
+ args = MagicMock(); args.target = "unknown"; args.out = None
238
+ # R5: unresolved target exits nonzero, error on stderr, stdout
239
+ # stays clean so ``conduct export ... > file.json`` never writes
240
+ # an error message into the destination file.
241
+ with pytest.raises(SystemExit) as exc_info:
242
+ cli.cmd_export_gateway_config(args)
243
+ assert exc_info.value.code == 1
244
+ captured = capsys.readouterr()
245
+ assert "No profile matched" in captured.err
246
+ assert captured.out == ""
247
+
248
+
249
+ def test_export_stdout_stays_clean_on_backend_error(tmp_path, monkeypatch, capsys):
250
+ """R5: an HTTPError during export must NOT contaminate stdout —
251
+ ``conduct export ... > profile.json`` piping into a file must
252
+ only see the profile JSON, never an error message."""
253
+ import urllib.error
254
+ err = urllib.error.HTTPError(
255
+ "http://x", 500, "Server Error", {},
256
+ io.BytesIO(b"boom"),
257
+ )
258
+ calls = []
259
+ def _fake(req, timeout=None):
260
+ if req.full_url.endswith("/export"):
261
+ raise err
262
+ return _mk_response([{"id": UUID, "cond_code": "abc"}])
263
+ monkeypatch.setattr(cli.urllib.request, "urlopen", _fake)
264
+ args = MagicMock(); args.target = UUID; args.out = None
265
+ with pytest.raises(SystemExit) as exc_info:
266
+ cli.cmd_export_gateway_config(args)
267
+ assert exc_info.value.code == 1
268
+ captured = capsys.readouterr()
269
+ assert captured.out == ""
270
+ assert "Export failed" in captured.err
271
+
272
+
273
+ def test_import_unauth_exits_nonzero_on_stderr(monkeypatch, capsys, tmp_path):
274
+ """R5: no token → exit 1, error on stderr."""
275
+ monkeypatch.setattr(
276
+ cli, "_require_auth",
277
+ lambda args: ("https://api.example.com", "ws-1", None),
278
+ )
279
+ src = tmp_path / "p.json"; src.write_text(json.dumps({"name": "x"}))
280
+ args = MagicMock(); args.file = str(src); args.name = None
281
+ with pytest.raises(SystemExit) as exc_info:
282
+ cli.cmd_import_gateway_config(args)
283
+ assert exc_info.value.code == 1
284
+ assert "Not authenticated" in capsys.readouterr().err
285
+
286
+
287
+ def test_export_out_writes_file(tmp_path, monkeypatch):
288
+ dest = tmp_path / "out.json"
289
+ monkeypatch.setattr(cli.urllib.request, "urlopen",
290
+ lambda req, timeout=None: _mk_response({"name": "x", "targets": []}))
291
+ args = MagicMock(); args.target = UUID; args.out = str(dest)
292
+ cli.cmd_export_gateway_config(args)
293
+ assert json.loads(dest.read_text()) == {"name": "x", "targets": []}
File without changes
File without changes
File without changes
File without changes