lightnow-cli 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. lightnow_cli-1.2.0/.release-please-manifest.json +3 -0
  2. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/CHANGELOG.md +14 -0
  3. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/PKG-INFO +3 -1
  4. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/__init__.py +1 -1
  5. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/integrations.py +108 -0
  6. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/runner.py +181 -1
  7. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/pyproject.toml +4 -1
  8. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_integrations.py +213 -0
  9. lightnow_cli-1.1.0/.release-please-manifest.json +0 -3
  10. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  11. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/release-please.yml +0 -0
  12. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/release.yml +0 -0
  13. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.gitignore +0 -0
  14. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/CONTRIBUTING.md +0 -0
  15. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/LICENSE +0 -0
  16. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/Makefile +0 -0
  17. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/README.md +0 -0
  18. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/SECURITY.md +0 -0
  19. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/docs/development.md +0 -0
  20. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/docs/release.md +0 -0
  21. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/README.md +0 -0
  22. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/docs.md +0 -0
  23. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/openapi.json +0 -0
  24. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/server.json +0 -0
  25. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/authenticated_http.py +0 -0
  26. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/client.py +0 -0
  27. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/__init__.py +0 -0
  28. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/auth.py +0 -0
  29. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/context.py +0 -0
  30. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/publish.py +0 -0
  31. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/query.py +0 -0
  32. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/validate.py +0 -0
  33. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/config.py +0 -0
  34. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/main.py +0 -0
  35. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/tls.py +0 -0
  36. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/validation.py +0 -0
  37. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/release-please-config.json +0 -0
  38. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/__init__.py +0 -0
  39. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_auth_flow.py +0 -0
  40. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_authenticated_http.py +0 -0
  41. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_cli.py +0 -0
  42. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_client.py +0 -0
  43. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_config.py +0 -0
  44. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_context.py +0 -0
  45. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_tls.py +0 -0
  46. {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_validation.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.0"
3
+ }
@@ -4,6 +4,20 @@ All notable changes to the LightNow CLI are documented here.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [1.2.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.1.0...v1.2.0) (2026-07-12)
8
+
9
+
10
+ ### Features
11
+
12
+ * **#5:** support runtime Vault bindings ([7750c27](https://github.com/lightnow-ai/lightnow-cli/commit/7750c27b87567012eadb6e96a934a25d5d205b0e))
13
+ * **secrets:** support runtime Vault bindings ([7cacff6](https://github.com/lightnow-ai/lightnow-cli/commit/7cacff66c162c1c320fef5195c6acd0f3a77ca45))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **#4:** fix/empty mcp server sync ([a2865bf](https://github.com/lightnow-ai/lightnow-cli/commit/a2865bf21ba7fba7b7bb7115388ad45f153c3280))
19
+ * **sync:** handle empty MCP server profiles ([065d940](https://github.com/lightnow-ai/lightnow-cli/commit/065d940ea0d7a4016705a8fb124ebdc07d204a7f))
20
+
7
21
  ## [1.1.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.0.5...v1.1.0) (2026-07-07)
8
22
 
9
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightnow-cli
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
5
5
  Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
6
6
  Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
@@ -40,6 +40,8 @@ Requires-Dist: responses>=0.23.0; extra == 'dev'
40
40
  Requires-Dist: twine>=5.0.0; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema>=4.25.0; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
43
+ Provides-Extra: keyring
44
+ Requires-Dist: keyring>=25.6.0; extra == 'keyring'
43
45
  Description-Content-Type: text/markdown
44
46
 
45
47
  # LightNow CLI
@@ -1,3 +1,3 @@
1
1
  """LightNow CLI - A command-line tool for managing MCP servers."""
2
2
 
3
- __version__ = "1.1.0" # x-release-please-version
3
+ __version__ = "1.2.0" # x-release-please-version
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import re
8
+ import shlex
8
9
  import shutil
9
10
  import subprocess
10
11
  import sys
@@ -222,6 +223,70 @@ def direct_server_aliases(content: str, export_format: str) -> list[str]:
222
223
  return sorted(alias for alias in entries if alias not in LIGHTNOW_PROXY_ALIASES)
223
224
 
224
225
 
226
+ def json_server_maps_are_empty(content: str) -> bool:
227
+ """Return whether a JSON export contains only empty server maps."""
228
+ payload = json.loads(content)
229
+ if not isinstance(payload, dict):
230
+ return False
231
+ sections = [payload[key] for key in ("mcpServers", "servers") if key in payload]
232
+ return bool(sections) and all(
233
+ isinstance(section, dict) and not section for section in sections
234
+ )
235
+
236
+
237
+ def empty_profile_import_command(
238
+ *,
239
+ client: str,
240
+ profile: str,
241
+ target: Path,
242
+ tenant: Optional[str],
243
+ api_url: Optional[str],
244
+ ) -> str:
245
+ """Build a copyable command that previews importing an existing client config."""
246
+ args = [
247
+ "lightnow",
248
+ "import-config",
249
+ "--client",
250
+ client,
251
+ "--profile",
252
+ profile,
253
+ "--config-path",
254
+ str(target),
255
+ "--dry-run",
256
+ ]
257
+ if tenant:
258
+ args.extend(["--tenant", tenant])
259
+ if api_url:
260
+ args.extend(["--api-url", api_url])
261
+ return shlex.join(args)
262
+
263
+
264
+ def print_empty_profile_import_hint(
265
+ *,
266
+ client: str,
267
+ profile: str,
268
+ target: Path,
269
+ aliases: list[str],
270
+ tenant: Optional[str],
271
+ api_url: Optional[str],
272
+ ) -> None:
273
+ """Explain that existing client servers were preserved and can be imported."""
274
+ err_console.print(
275
+ f"[yellow]LightNow profile {profile} has no MCP servers, but {client} already "
276
+ f"configures: {', '.join(aliases)}. These existing client entries were kept.[/yellow]"
277
+ )
278
+ command = empty_profile_import_command(
279
+ client=client,
280
+ profile=profile,
281
+ target=target,
282
+ tenant=tenant,
283
+ api_url=api_url,
284
+ )
285
+ err_console.print(
286
+ f"[cyan]To preview importing them into LightNow, run:[/cyan] {command}"
287
+ )
288
+
289
+
225
290
  @app.command("sync")
226
291
  def sync(
227
292
  client: Annotated[str, typer.Option("--client", help="Target MCP client")],
@@ -357,6 +422,7 @@ def sync(
357
422
  proxy_default_alias: Optional[Path] = None
358
423
  settings_local_proxy_summary: dict[str, Any] = {}
359
424
  removed_direct_servers: list[str] = []
425
+ empty_profile_existing_aliases: list[str] = []
360
426
  remove_unmanaged_client_servers = local_proxy
361
427
  policy_managed_sync = False
362
428
 
@@ -413,6 +479,7 @@ def sync(
413
479
  local_proxy_settings=(
414
480
  settings_local_proxy_summary if from_settings else None
415
481
  ),
482
+ runtime_secrets=read_local_runtime_secrets(proxy_target),
416
483
  )
417
484
  elif runner:
418
485
  profile_payload = fetch_profile_servers(
@@ -468,6 +535,18 @@ def sync(
468
535
  previous_managed["aliases"],
469
536
  previous_managed["input_ids"],
470
537
  )
538
+ if (
539
+ not local_proxy
540
+ and not runner
541
+ and export_format == "json"
542
+ and json_server_maps_are_empty(generated)
543
+ ):
544
+ managed_aliases = set(previous_managed["aliases"])
545
+ empty_profile_existing_aliases = [
546
+ alias
547
+ for alias in direct_server_aliases(existing, export_format)
548
+ if alias not in managed_aliases
549
+ ]
471
550
  except AccessTokenExpired:
472
551
  console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
473
552
  raise typer.Exit(1)
@@ -483,6 +562,16 @@ def sync(
483
562
  console.print(f"[bold red]Integration sync failed:[/bold red] {exc}")
484
563
  raise typer.Exit(1) from exc
485
564
 
565
+ if empty_profile_existing_aliases:
566
+ print_empty_profile_import_hint(
567
+ client=client,
568
+ profile=profile,
569
+ target=target,
570
+ aliases=empty_profile_existing_aliases,
571
+ tenant=tenant,
572
+ api_url=api_url,
573
+ )
574
+
486
575
  if dry_run:
487
576
  typer.echo(redact(patched))
488
577
  err_console.print("[cyan]Dry run: nothing was written.[/cyan]")
@@ -886,6 +975,7 @@ def build_local_proxy_config(
886
975
  tenant: Optional[str],
887
976
  registry_ca_file: Optional[Path] = None,
888
977
  local_proxy_settings: Optional[dict[str, Any]] = None,
978
+ runtime_secrets: Optional[dict[str, Any]] = None,
889
979
  ) -> str:
890
980
  """Render the local LightNow Proxy config for LightNow-managed profile sync."""
891
981
  parsed = urlparse(local_proxy_url)
@@ -952,12 +1042,30 @@ def build_local_proxy_config(
952
1042
  "jwks_cache_seconds": 300,
953
1043
  },
954
1044
  "registry_api": registry_api,
1045
+ "runtime_secrets": runtime_secrets or {"providers": {}},
955
1046
  "profiles": {profile: {}},
956
1047
  "upstreams": {},
957
1048
  }
958
1049
  return cast(str, yaml.safe_dump(payload, sort_keys=False))
959
1050
 
960
1051
 
1052
+ def read_local_runtime_secrets(path: Path) -> dict[str, Any]:
1053
+ """Preserve non-secret local provider mappings across managed config syncs."""
1054
+ try:
1055
+ payload = yaml.safe_load(path.expanduser().read_text()) or {}
1056
+ except (FileNotFoundError, OSError, yaml.YAMLError):
1057
+ return {"providers": {}}
1058
+ if not isinstance(payload, dict):
1059
+ return {"providers": {}}
1060
+ runtime_secrets = payload.get("runtime_secrets")
1061
+ if not isinstance(runtime_secrets, dict):
1062
+ return {"providers": {}}
1063
+ providers = runtime_secrets.get("providers")
1064
+ if not isinstance(providers, dict):
1065
+ return {"providers": {}}
1066
+ return {"providers": providers}
1067
+
1068
+
961
1069
  def build_runner_export(
962
1070
  *,
963
1071
  profile_payload: dict[str, Any],
@@ -2,12 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ipaddress
5
6
  import os
6
7
  import subprocess
7
8
  import sys
8
9
  from pathlib import Path
9
10
  from typing import Any, Optional
10
- from urllib.parse import quote
11
+ from urllib.parse import quote, urlparse
11
12
 
12
13
  import httpx
13
14
  import typer
@@ -83,6 +84,7 @@ def run(
83
84
  version=str(selected["version"]),
84
85
  transport=transport,
85
86
  )
87
+ resolve_external_secret_bindings(context)
86
88
  assert_runtime_context_ready(context, server=server, profile=profile)
87
89
  command, args, env, cwd = launch_config_from_context(context)
88
90
  except AccessTokenExpired:
@@ -369,6 +371,184 @@ def launch_config_from_context(
369
371
  return command, list(args), env, cwd
370
372
 
371
373
 
374
+ def resolve_external_secret_bindings(context: dict[str, Any]) -> None:
375
+ """Resolve runtime-only Vault bindings locally without persisting plaintext."""
376
+ bindings = context.get("external_secret_bindings")
377
+ if bindings is None:
378
+ return
379
+ if not isinstance(bindings, list):
380
+ raise ValueError("Runtime context included invalid external secret bindings.")
381
+
382
+ for binding in bindings:
383
+ if not isinstance(binding, dict):
384
+ raise ValueError(
385
+ "Runtime context included an invalid external secret binding."
386
+ )
387
+ provider = binding.get("provider")
388
+ locator = binding.get("locator")
389
+ target = binding.get("target")
390
+ probe = context.get("probe_request")
391
+ if not all(
392
+ isinstance(item, dict) for item in (provider, locator, target, probe)
393
+ ):
394
+ raise ValueError("Runtime external secret binding is incomplete.")
395
+ assert isinstance(provider, dict)
396
+ assert isinstance(locator, dict)
397
+ assert isinstance(target, dict)
398
+ assert isinstance(probe, dict)
399
+
400
+ provider_id = provider.get("id")
401
+ if (
402
+ provider.get("provider_type") != "vault_kv_v2"
403
+ or provider.get("resolution_mode") != "runtime"
404
+ ):
405
+ raise ValueError("Runtime external secret provider is unsupported.")
406
+ path = locator.get("path")
407
+ field = locator.get("field")
408
+ name = target.get("name")
409
+ if not all(
410
+ isinstance(item, str) and item for item in (provider_id, path, field, name)
411
+ ):
412
+ raise ValueError("Runtime external secret binding is incomplete.")
413
+
414
+ target_type = target.get("type")
415
+ transport = probe.get("transport")
416
+ if not (
417
+ (target_type == "env" and transport == "stdio")
418
+ or (target_type == "header" and transport in {"http", "sse"})
419
+ ):
420
+ raise ValueError(
421
+ "Runtime external secret target does not match the selected transport."
422
+ )
423
+
424
+ raw_config = provider.get("config")
425
+ config: dict[str, Any] = (
426
+ {str(key): item for key, item in raw_config.items()}
427
+ if isinstance(raw_config, dict)
428
+ else {}
429
+ )
430
+ value = resolve_vault_runtime_secret(
431
+ provider_id=str(provider_id),
432
+ config=config,
433
+ path=str(path),
434
+ field=str(field),
435
+ )
436
+ if target_type == "env":
437
+ stdio = probe.setdefault("stdio", {})
438
+ if not isinstance(stdio, dict):
439
+ raise ValueError(
440
+ "Runtime context included invalid stdio configuration."
441
+ )
442
+ env = stdio.setdefault("env", {})
443
+ if not isinstance(env, dict):
444
+ raise ValueError(
445
+ "Runtime context included invalid environment variables."
446
+ )
447
+ env[str(name)] = value
448
+ else:
449
+ remote = probe.setdefault(str(transport), {})
450
+ if not isinstance(remote, dict):
451
+ raise ValueError(
452
+ "Runtime context included invalid remote configuration."
453
+ )
454
+ headers = remote.setdefault("headers", {})
455
+ if not isinstance(headers, dict):
456
+ raise ValueError("Runtime context included invalid HTTP headers.")
457
+ headers[str(name)] = value
458
+
459
+
460
+ def resolve_vault_runtime_secret(
461
+ *, provider_id: str, config: dict[str, Any], path: str, field: str
462
+ ) -> str:
463
+ """Read one Vault KV v2 scalar through Vault Proxy or an opt-in OS keyring token."""
464
+ auth_mode = os.environ.get("LIGHTNOW_VAULT_AUTH_MODE", "vault-proxy")
465
+ headers: dict[str, str] = {}
466
+ namespace = config.get("namespace")
467
+ if isinstance(namespace, str) and namespace:
468
+ headers["X-Vault-Namespace"] = namespace
469
+
470
+ address: str
471
+ if auth_mode == "vault-proxy":
472
+ address = os.environ.get("LIGHTNOW_VAULT_PROXY_URL", "http://127.0.0.1:8200")
473
+ parsed = urlparse(address)
474
+ host = parsed.hostname
475
+ is_loopback = host == "localhost"
476
+ if host is not None:
477
+ try:
478
+ is_loopback = is_loopback or ipaddress.ip_address(host).is_loopback
479
+ except ValueError:
480
+ pass
481
+ if parsed.scheme not in {"http", "https"} or not is_loopback:
482
+ raise ValueError(
483
+ "LIGHTNOW_VAULT_PROXY_URL must target an HTTP(S) loopback address."
484
+ )
485
+ elif auth_mode == "keyring":
486
+ configured_address = os.environ.get("LIGHTNOW_VAULT_ADDRESS") or config.get(
487
+ "address"
488
+ )
489
+ if not isinstance(configured_address, str) or not configured_address:
490
+ raise ValueError(
491
+ f"Vault address is missing for runtime provider {provider_id}."
492
+ )
493
+ address = configured_address
494
+ parsed = urlparse(address)
495
+ if parsed.scheme != "https" or parsed.hostname is None:
496
+ raise ValueError("Runtime Vault address must be an HTTPS origin.")
497
+ try:
498
+ import keyring # type: ignore[import-not-found]
499
+ except ImportError as exc:
500
+ raise ValueError(
501
+ "OS-keyring support is not installed. Install lightnow-cli[keyring] or use Vault Proxy."
502
+ ) from exc
503
+ service = os.environ.get(
504
+ "LIGHTNOW_VAULT_KEYRING_SERVICE", "lightnow-proxy-vault"
505
+ )
506
+ token = keyring.get_password(service, provider_id)
507
+ if not isinstance(token, str) or not token:
508
+ raise ValueError(
509
+ f"OS keyring has no Vault token for runtime provider {provider_id}."
510
+ )
511
+ headers["X-Vault-Token"] = token
512
+ else:
513
+ raise ValueError("LIGHTNOW_VAULT_AUTH_MODE must be vault-proxy or keyring.")
514
+
515
+ verify: str | bool = os.environ.get("LIGHTNOW_VAULT_CA_FILE") or True
516
+ url = f"{address.rstrip('/')}/v1/{path.lstrip('/')}"
517
+ try:
518
+ response = httpx.get(
519
+ url, headers=headers, timeout=10.0, verify=verify, follow_redirects=False
520
+ )
521
+ except httpx.RequestError as exc:
522
+ raise ValueError(
523
+ f"Runtime Vault provider {provider_id} is not reachable."
524
+ ) from exc
525
+ if response.is_redirect:
526
+ raise ValueError(f"Runtime Vault provider {provider_id} returned a redirect.")
527
+ if response.status_code >= 400:
528
+ raise ValueError(
529
+ f"Runtime Vault provider {provider_id} returned HTTP {response.status_code}."
530
+ )
531
+ if len(response.content) > 64 * 1024:
532
+ raise ValueError(
533
+ f"Runtime Vault provider {provider_id} returned an oversized response."
534
+ )
535
+ try:
536
+ payload = response.json()
537
+ except ValueError as exc:
538
+ raise ValueError(
539
+ f"Runtime Vault provider {provider_id} returned invalid JSON."
540
+ ) from exc
541
+ payload_data = payload.get("data") if isinstance(payload, dict) else None
542
+ data = payload_data if isinstance(payload_data, dict) else {}
543
+ secret_data = data.get("data") if isinstance(data.get("data"), dict) else data
544
+ value = secret_data.get(field) if isinstance(secret_data, dict) else None
545
+ if not isinstance(value, str | int | float | bool):
546
+ raise ValueError(
547
+ f"Runtime Vault provider {provider_id} did not return the configured scalar field."
548
+ )
549
+ return str(value)
550
+
551
+
372
552
  def json_response_or_error(response: httpx.Response) -> dict[str, Any]:
373
553
  """Return JSON response or raise a redacted explicit error."""
374
554
  if response.status_code == 401:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lightnow-cli"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows"
9
9
  authors = [{name = "LightNow Team"}]
10
10
  license = {text = "Apache-2.0"}
@@ -34,6 +34,9 @@ dependencies = [
34
34
  ]
35
35
 
36
36
  [project.optional-dependencies]
37
+ keyring = [
38
+ "keyring>=25.6.0",
39
+ ]
37
40
  dev = [
38
41
  "pytest>=7.0.0",
39
42
  "pytest-cov>=4.0.0",
@@ -38,6 +38,7 @@ from lightnow_cli.commands.integrations import (
38
38
  prepare_codex_local_proxy_config,
39
39
  prepare_json_local_proxy_config,
40
40
  print_import_summary,
41
+ read_local_runtime_secrets,
41
42
  redact,
42
43
  render_local_proxy_codex_stdio_toml,
43
44
  render_local_proxy_codex_toml,
@@ -51,7 +52,9 @@ from lightnow_cli.commands.runner import (
51
52
  fetch_runtime_context,
52
53
  json_response_or_error,
53
54
  launch_config_from_context,
55
+ resolve_external_secret_bindings,
54
56
  resolve_profile_server,
57
+ resolve_vault_runtime_secret,
55
58
  runner_failure_summary,
56
59
  )
57
60
  from lightnow_cli.main import app
@@ -302,6 +305,67 @@ def test_sync_json_writes_manifest_and_preserves_user_config() -> None:
302
305
  assert target.with_suffix(".json.lightnow.bak").exists()
303
306
 
304
307
 
308
+ def test_empty_antigravity_profile_preserves_servers_and_suggests_import() -> None:
309
+ """An empty server map preserves client servers and suggests importing them."""
310
+ runner = CliRunner()
311
+ with tempfile.TemporaryDirectory() as tmp:
312
+ target = Path(tmp) / "mcp_config.json"
313
+ target.write_text(
314
+ json.dumps(
315
+ {
316
+ "mcpServers": {
317
+ "local-redis": {
318
+ "command": "docker",
319
+ "args": ["run", "--rm", "mcp/redis"],
320
+ }
321
+ }
322
+ }
323
+ )
324
+ )
325
+
326
+ with (
327
+ patch(
328
+ "lightnow_cli.commands.integrations.require_access_token",
329
+ return_value="token",
330
+ ),
331
+ patch(
332
+ "lightnow_cli.commands.integrations.fetch_export",
333
+ return_value='{"mcpServers": {}}',
334
+ ),
335
+ ):
336
+ result = runner.invoke(
337
+ app,
338
+ [
339
+ "sync",
340
+ "--client",
341
+ "antigravity",
342
+ "--config-path",
343
+ str(target),
344
+ "--yes",
345
+ ],
346
+ )
347
+
348
+ payload = json.loads(target.read_text())
349
+
350
+ assert result.exit_code == 0
351
+ assert payload["mcpServers"]["local-redis"]["command"] == "docker"
352
+ assert "LightNow profile default has no MCP servers" in result.stderr
353
+ assert "These existing client entries were kept" in result.stderr
354
+ compact_stderr = result.stderr.replace("\n", "")
355
+ assert "lightnow import-config --client antigravity" in compact_stderr
356
+ assert f"--config-path {target}" in compact_stderr
357
+ assert "--dry-run" in compact_stderr
358
+
359
+
360
+ def test_json_sync_rejects_non_object_server_map() -> None:
361
+ """JSON exports must provide object-shaped server maps."""
362
+ with pytest.raises(
363
+ ValueError,
364
+ match="LightNow can only sync JSON object field mcpServers",
365
+ ):
366
+ patch_config("{}", '{"mcpServers": []}', "json")
367
+
368
+
305
369
  def test_sync_json_preserves_user_inputs_and_replaces_lightnow_inputs() -> None:
306
370
  """VS Code sync patches inputs without deleting user-owned prompts."""
307
371
  existing = json.dumps(
@@ -2150,6 +2214,155 @@ def test_launch_config_from_context_extracts_secret_environment() -> None:
2150
2214
  assert cwd == "~/code"
2151
2215
 
2152
2216
 
2217
+ def test_runner_resolves_runtime_vault_binding_through_local_vault_proxy(
2218
+ monkeypatch,
2219
+ ) -> None:
2220
+ """Runtime-only values are fetched locally and never require an API-held token."""
2221
+ monkeypatch.setenv("LIGHTNOW_VAULT_PROXY_URL", "http://127.0.0.1:18200")
2222
+ context = {
2223
+ "probe_request": {"transport": "stdio", "stdio": {"cmd": "docker", "env": {}}},
2224
+ "external_secret_bindings": [
2225
+ {
2226
+ "provider": {
2227
+ "id": "provider-uuid",
2228
+ "provider_type": "vault_kv_v2",
2229
+ "resolution_mode": "runtime",
2230
+ "config": {
2231
+ "address": "https://vault.customer.internal",
2232
+ "namespace": "customer",
2233
+ },
2234
+ },
2235
+ "locator": {"path": "secret/data/lightnow/test", "field": "token"},
2236
+ "target": {"type": "env", "name": "API_TOKEN"},
2237
+ }
2238
+ ],
2239
+ }
2240
+ response = httpx.Response(
2241
+ 200,
2242
+ request=httpx.Request(
2243
+ "GET", "http://127.0.0.1:18200/v1/secret/data/lightnow/test"
2244
+ ),
2245
+ json={"data": {"data": {"token": "runtime-value"}}},
2246
+ )
2247
+
2248
+ with patch(
2249
+ "lightnow_cli.commands.runner.httpx.get", return_value=response
2250
+ ) as vault_get:
2251
+ resolve_external_secret_bindings(context)
2252
+
2253
+ assert context["probe_request"]["stdio"]["env"]["API_TOKEN"] == "runtime-value"
2254
+ assert (
2255
+ vault_get.call_args.args[0]
2256
+ == "http://127.0.0.1:18200/v1/secret/data/lightnow/test"
2257
+ )
2258
+ assert vault_get.call_args.kwargs["headers"] == {"X-Vault-Namespace": "customer"}
2259
+ assert vault_get.call_args.kwargs["follow_redirects"] is False
2260
+
2261
+
2262
+ def test_runner_rejects_runtime_binding_target_before_contacting_vault() -> None:
2263
+ context = {
2264
+ "probe_request": {"transport": "stdio", "stdio": {"cmd": "docker"}},
2265
+ "external_secret_bindings": [
2266
+ {
2267
+ "provider": {
2268
+ "id": "provider-uuid",
2269
+ "provider_type": "vault_kv_v2",
2270
+ "resolution_mode": "runtime",
2271
+ "config": {},
2272
+ },
2273
+ "locator": {"path": "secret/data/lightnow/test", "field": "token"},
2274
+ "target": {"type": "header", "name": "Authorization"},
2275
+ }
2276
+ ],
2277
+ }
2278
+
2279
+ with patch("lightnow_cli.commands.runner.httpx.get") as vault_get:
2280
+ try:
2281
+ resolve_external_secret_bindings(context)
2282
+ except ValueError as exc:
2283
+ assert "does not match" in str(exc)
2284
+ else:
2285
+ raise AssertionError("expected ValueError")
2286
+
2287
+ vault_get.assert_not_called()
2288
+
2289
+
2290
+ def test_runtime_vault_resolution_fails_closed_for_unsafe_proxy_and_responses(
2291
+ monkeypatch,
2292
+ ) -> None:
2293
+ monkeypatch.setenv("LIGHTNOW_VAULT_PROXY_URL", "http://vault.attacker.example:8200")
2294
+ try:
2295
+ resolve_vault_runtime_secret(
2296
+ provider_id="provider-uuid",
2297
+ config={},
2298
+ path="secret/data/test",
2299
+ field="token",
2300
+ )
2301
+ except ValueError as exc:
2302
+ assert "loopback" in str(exc)
2303
+ else:
2304
+ raise AssertionError("expected ValueError")
2305
+
2306
+ monkeypatch.setenv("LIGHTNOW_VAULT_PROXY_URL", "http://127.0.0.1:8200")
2307
+ responses = [
2308
+ (
2309
+ httpx.Response(302, headers={"Location": "http://169.254.169.254"}),
2310
+ "redirect",
2311
+ ),
2312
+ (httpx.Response(503), "HTTP 503"),
2313
+ (httpx.Response(200, content=b"x" * (64 * 1024 + 1)), "oversized"),
2314
+ (httpx.Response(200, content=b"not-json"), "invalid JSON"),
2315
+ (httpx.Response(200, json={"data": {"data": {}}}), "scalar field"),
2316
+ ]
2317
+ for response, expected in responses:
2318
+ response.request = httpx.Request(
2319
+ "GET", "http://127.0.0.1:8200/v1/secret/data/test"
2320
+ )
2321
+ with patch("lightnow_cli.commands.runner.httpx.get", return_value=response):
2322
+ try:
2323
+ resolve_vault_runtime_secret(
2324
+ provider_id="provider-uuid",
2325
+ config={},
2326
+ path="secret/data/test",
2327
+ field="token",
2328
+ )
2329
+ except ValueError as exc:
2330
+ assert expected in str(exc)
2331
+ else:
2332
+ raise AssertionError("expected ValueError")
2333
+
2334
+
2335
+ def test_local_proxy_sync_preserves_runtime_secret_provider_mappings(tmp_path) -> None:
2336
+ config_path = tmp_path / "codex.yaml"
2337
+ config_path.write_text(
2338
+ yaml.safe_dump(
2339
+ {
2340
+ "runtime_secrets": {
2341
+ "providers": {
2342
+ "provider-uuid": {
2343
+ "auth_mode": "vault-proxy",
2344
+ "vault_proxy_url": "http://127.0.0.1:18200",
2345
+ }
2346
+ }
2347
+ }
2348
+ }
2349
+ )
2350
+ )
2351
+
2352
+ preserved = read_local_runtime_secrets(config_path)
2353
+ rendered = yaml.safe_load(
2354
+ build_local_proxy_config(
2355
+ local_proxy_url="http://127.0.0.1:8765/mcp",
2356
+ profile="default",
2357
+ registry_api_url="https://registry-api.lightnow.ai",
2358
+ tenant=None,
2359
+ runtime_secrets=preserved,
2360
+ )
2361
+ )
2362
+
2363
+ assert rendered["runtime_secrets"] == preserved
2364
+
2365
+
2153
2366
  def test_runner_context_readiness_reports_missing_inputs() -> None:
2154
2367
  """The runner blocks before starting a child process when config is incomplete."""
2155
2368
  try:
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.1.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes