diff --git a/backend/tests/test_dev_multi_instance_script.py b/backend/tests/test_dev_multi_instance_script.py index 2cd9f668e1..654ea7937c 100644 --- a/backend/tests/test_dev_multi_instance_script.py +++ b/backend/tests/test_dev_multi_instance_script.py @@ -445,6 +445,64 @@ def test_nginx_conf_is_accepted_by_nginx(tmp_path, state_name): assert result.returncode == 0, result.stderr +# --------------------------------------------------------------------------- +# --out validation +# --------------------------------------------------------------------------- + +_OUT_ARGV = { + "render-config": lambda out: ["render-config", "--base", str(REPO_ROOT / "config.example.yaml"), "--out", str(out), "--database-url", DATABASE_URL, "--redis-url", REDIS_URL, "--state-dir", str(out.parent / "state")], + "secrets": lambda out: ["secrets", "--out", str(out)], + "nginx-conf": lambda out: ["nginx-conf", "--out", str(out), "--state-dir", str(out.parent / "state"), "--listen-port", "2027", "--gateway-port", "8001", "--gateway-port", "8011"], +} + + +@pytest.mark.parametrize("command", sorted(_OUT_ARGV)) +def test_cli_rejects_a_directory_out_before_writing(tmp_path, capsys, command): + out = tmp_path / "bundle" + out.mkdir() + + with pytest.raises(SystemExit) as exit_info: + mi.main(_OUT_ARGV[command](out)) + + assert exit_info.value.code == 2 + assert f"--out must be a file path, not a directory: {out}" in capsys.readouterr().err + assert list(tmp_path.iterdir()) == [out], "a rejected --out must not leave a temporary file behind" + + +@pytest.mark.parametrize("command", sorted(_OUT_ARGV)) +def test_cli_rejects_an_out_whose_parent_is_a_file(tmp_path, capsys, command): + parent = tmp_path / "not-a-dir" + parent.write_text("", encoding="utf-8") + + with pytest.raises(SystemExit) as exit_info: + mi.main(_OUT_ARGV[command](parent / "bundle")) + + assert exit_info.value.code == 2 + assert f"--out parent must be a directory: {parent}" in capsys.readouterr().err + + +@pytest.mark.parametrize("missing", ["missing", "a/b"]) +@pytest.mark.parametrize("command", sorted(_OUT_ARGV)) +def test_cli_rejects_a_file_ancestor_behind_missing_directories(tmp_path, capsys, command, missing): + blocker = tmp_path / "blocker" + blocker.write_text("", encoding="utf-8") + + with pytest.raises(SystemExit) as exit_info: + mi.main(_OUT_ARGV[command](blocker / missing / "secrets.env")) + + assert exit_info.value.code == 2 + assert f"--out parent must be a directory: {blocker}" in capsys.readouterr().err + + +@pytest.mark.parametrize("command", sorted(_OUT_ARGV)) +def test_cli_still_creates_missing_out_parents(tmp_path, command): + out = tmp_path / "a" / "b" / "secrets.env" + + assert mi.main(_OUT_ARGV[command](out)) == 0 + + assert out.is_file() + + # --------------------------------------------------------------------------- # Check helpers # --------------------------------------------------------------------------- diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 6db8365975..6834fcfb3f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -213,6 +213,8 @@ multi-instance gate and every endpoint/data root inside its state dir Unless `keep_channels` is requested, disable provider flags in both `channels` and `channel_connections`; UI credentials can otherwise connect a bot later. Preserve `channels.leader_lease` and connection metadata. +`--out` is a file path for `render-config`, `secrets` and `nginx-conf`; a +directory, or a file ancestor, is rejected before any write. ## Shell Script Invocation Contract diff --git a/scripts/dev_multi_instance.py b/scripts/dev_multi_instance.py index 46a0cbe2b0..fab8f991f6 100644 --- a/scripts/dev_multi_instance.py +++ b/scripts/dev_multi_instance.py @@ -740,17 +740,17 @@ def main(argv: Sequence[str] | None = None) -> int: render = sub.add_parser("render-config", help="write the multi-instance config.yaml") render.add_argument("--base", required=True, type=Path) - render.add_argument("--out", required=True, type=Path) + render.add_argument("--out", required=True, type=Path, help="output file path (not a directory)") render.add_argument("--database-url", required=True) render.add_argument("--redis-url", required=True) render.add_argument("--state-dir", required=True, type=Path) render.add_argument("--keep-channels", action="store_true") secrets_cmd = sub.add_parser("secrets", help="create the shared secrets file if missing") - secrets_cmd.add_argument("--out", required=True, type=Path) + secrets_cmd.add_argument("--out", required=True, type=Path, help="output file path (not a directory)") nginx = sub.add_parser("nginx-conf", help="write the round-robin nginx config") - nginx.add_argument("--out", required=True, type=Path) + nginx.add_argument("--out", required=True, type=Path, help="output file path (not a directory)") nginx.add_argument("--state-dir", required=True, type=Path) nginx.add_argument("--listen-port", required=True, type=int) nginx.add_argument("--gateway-port", required=True, type=int, action="append") @@ -765,6 +765,17 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) + out = getattr(args, "out", None) + if out is not None: + # Both shapes reach the writers as a raw OSError (EISDIR/ENOTDIR) after doing work. + if out.is_dir(): + parser.error(f"--out must be a file path, not a directory: {out}") + for ancestor in out.parents: + if ancestor.exists(): + if not ancestor.is_dir(): + parser.error(f"--out parent must be a directory: {ancestor}") + break + if args.command == "render-config": for note in render_config(args.base, args.out, database_url=args.database_url, redis_url=args.redis_url, state_dir=args.state_dir, keep_channels=args.keep_channels): print(f" note: {note}")