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.
- lightnow_cli-1.2.0/.release-please-manifest.json +3 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/CHANGELOG.md +14 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/PKG-INFO +3 -1
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/integrations.py +108 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/runner.py +181 -1
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/pyproject.toml +4 -1
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_integrations.py +213 -0
- lightnow_cli-1.1.0/.release-please-manifest.json +0 -3
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/release-please.yml +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.github/workflows/release.yml +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/.gitignore +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/LICENSE +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/Makefile +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/README.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/SECURITY.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/docs/development.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/docs/release.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/README.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/docs.md +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/openapi.json +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/examples/server.json +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/auth.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/context.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/query.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/config.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/main.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/release-please-config.json +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/__init__.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_auth_flow.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_authenticated_http.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_cli.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_client.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_config.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_context.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_tls.py +0 -0
- {lightnow_cli-1.1.0 → lightnow_cli-1.2.0}/tests/test_validation.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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.
|
|
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:
|
|
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
|