From ef9cb6e159fd7b81f9e696ae4e60aa3526db6753 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 18:04:53 -0700 Subject: [PATCH 1/8] [CAN-282] Sweep the whole project into images, not only .py The build's fallback sweep collected only `.py`, so anything else the source opens at runtime -- PDFs, notes.txt, pyproject.toml, langgraph.json -- never reached the image, and the port failed only once the agent was serving. This accounts for 13 findings across 10 corpus repos. `_sweep_py_files` becomes `_sweep_project_files` and takes every file at its relative path. The rename also fixes the porting skill's capability probe, which checked `hasattr(stub_generator, "_sweep_project_files")` and so reported `sweeps_all_files: no` unconditionally. Broadening the sweep needs guardrails, since "copy everything" would otherwise bake secrets and host junk into images: - skipped: hidden files and directories (where .env and .git live), the generated stubs/, grpc_stubs/, docker_container/ at the root, __pycache__, node_modules, venv, site-packages, *.egg-info, host bytecode, and symlinks; - skipped with a warning: private key material (.pem, .key, .p12, .pfx), and the root Dockerfile, requirements.txt, and workflow_launcher.py. The build context writes its requirements.txt before the copy runs, so a project's own file at that name would have overwritten the generated one and stripped the base runtime dependencies out of the image. --- .../references/runtime-contract.md | 8 +- tests/test_stub_generator.py | 136 ++++++++++++++++++ ventis/stub_generator.py | 74 ++++++++-- 3 files changed, 204 insertions(+), 14 deletions(-) diff --git a/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md index 94f7401..a2f3b7e 100644 --- a/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md +++ b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md @@ -127,7 +127,13 @@ workflow_launcher.py Also avoid a yaml basename that shadows a different source module imported by an adapter. The validator checks deterministic flat-name collisions. -File sweep and editable-install behavior are runtime capabilities. For nested +File sweep and editable-install behavior are runtime capabilities. Where the +full project-file sweep is available, every project file ships at its relative +path, not only `.py` -- data files, prompts, and framework config included. It +leaves behind hidden files and directories, the generated `stubs/`, +`grpc_stubs/`, and `docker_container/`, host caches and virtualenvs, private key +material, and the root filenames the build context owns. A file the source opens +at runtime must therefore not be hidden or named like key material. For nested imports, follow [packaging.md](packaging.md). ## Dependencies and protobuf diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index fb01f2e..0631cb3 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -11,6 +11,7 @@ from ventis.stub_generator import ( BASE_AGENT_REQUIREMENTS, BASE_WORKFLOW_REQUIREMENTS, + _sweep_project_files, generate_docker, generate_workflow_docker, ) @@ -86,5 +87,140 @@ def test_per_workflow_requirements_are_appended_to_base(self): self.assertEqual(requirements, BASE_WORKFLOW_REQUIREMENTS + ["yfinance"]) +def _write(path, content="x"): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +class ProjectSweepTests(unittest.TestCase): + """The sweep carries the whole project, not only its .py files.""" + + def _swept(self, project_dir): + return {rel for _, rel in _sweep_project_files(str(project_dir))} + + def test_non_python_files_are_swept_with_their_layout(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "notes.txt") + _write(project / "pyproject.toml") + _write(project / "langgraph.json") + _write(project / "agent.py") + _write(project / "docs" / "manual.pdf") + _write(project / "src" / "pkg" / "prompts" / "system.md") + + swept = self._swept(project) + + self.assertEqual( + swept, + { + "notes.txt", + "pyproject.toml", + "langgraph.json", + "agent.py", + os.path.join("docs", "manual.pdf"), + os.path.join("src", "pkg", "prompts", "system.md"), + }, + ) + + def test_generated_hidden_and_host_local_paths_are_left_behind(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "keep.txt") + _write(project / ".env", "OPENAI_API_KEY=real") + _write(project / ".config" / "settings.json") + _write(project / "stubs" / "Old.py") + _write(project / "grpc_stubs" / "old_pb2.py") + _write(project / "docker_container" / "Agent" / "Dockerfile") + _write(project / "__pycache__" / "agent.cpython-311.pyc") + _write(project / "venv" / "lib" / "site.py") + _write(project / "node_modules" / "left-pad" / "index.js") + _write(project / "proj.egg-info" / "PKG-INFO") + _write(project / "compiled.pyc") + _write(project / "client.pem", "-----BEGIN PRIVATE KEY-----") + + swept = self._swept(project) + + self.assertEqual(swept, {"keep.txt"}) + + def test_generated_directory_names_are_only_reserved_at_the_root(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "stubs" / "Generated.py") + _write(project / "src" / "stubs" / "handwritten.py") + + swept = self._swept(project) + + self.assertEqual(swept, {os.path.join("src", "stubs", "handwritten.py")}) + + def test_symlinks_are_not_followed(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "real.txt") + (project / "link.txt").symlink_to(project / "real.txt") + + swept = self._swept(project) + + self.assertEqual(swept, {"real.txt"}) + + def test_project_requirements_does_not_replace_the_generated_one(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "requirements.txt", "yfinance==0.1\n") + yaml_path = project / "ExampleAgent.yaml" + yaml_path.write_text(yaml.safe_dump({"agent": {"name": "ExampleAgent"}})) + agent_file = _write(project / "agent.py", "print('ok')\n") + output_dir = os.path.join(tmpdir, "out") + + generate_docker( + str(yaml_path), + str(agent_file), + output_dir=output_dir, + project_dir=str(project), + ) + + requirements = _read_requirements(output_dir) + + self.assertEqual(requirements, BASE_AGENT_REQUIREMENTS) + + def test_agent_context_receives_the_swept_project(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + yaml_path = project / "ExampleAgent.yaml" + yaml_path.write_text(yaml.safe_dump({"agent": {"name": "ExampleAgent"}})) + agent_file = _write(project / "agent.py", "print('ok')\n") + _write(project / "data" / "handbook.pdf", "%PDF-1.4") + output_dir = os.path.join(tmpdir, "out") + + generate_docker( + str(yaml_path), + str(agent_file), + output_dir=output_dir, + project_dir=str(project), + ) + + copied = Path(output_dir) / "data" / "handbook.pdf" + + self.assertEqual(copied.read_text(), "%PDF-1.4") + + def test_workflow_context_receives_the_swept_project(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + workflow_file = _write(project / "workflow.py", "print('ok')\n") + _write(project / "config" / "langgraph.json", "{}") + output_dir = os.path.join(tmpdir, "out") + + generate_workflow_docker( + str(workflow_file), + [], + output_dir=output_dir, + project_dir=str(project), + ) + + copied = Path(output_dir) / "config" / "langgraph.json" + + self.assertEqual(copied.read_text(), "{}") + + if __name__ == "__main__": unittest.main() diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 54a46b9..72c262d 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -274,22 +274,70 @@ def _format_source(source): # Directories ventis build itself generates inside a project -- never swept. _GENERATED_DIRS = {"docker_container", "stubs", "grpc_stubs"} - -def _sweep_py_files(project_dir): - """Recursively collect (abs_src, rel_dst) for every .py file under project_dir, preserving its directory structure.""" +# Host-local noise: caches, virtualenvs, and vendored dependency trees. The +# image installs its own dependencies for its own platform, so these are at +# best dead weight and at worst the wrong architecture. +_SKIPPED_DIRS = {"__pycache__", "node_modules", "venv", "site-packages"} + +# Filenames the generator writes into the build context itself. A project file +# of the same name at the root would collide with the generated one. +_RESERVED_CONTEXT_NAMES = { + "Dockerfile", + "requirements.txt", + "workflow_launcher.py", +} + +# Compiled bytecode built against the host interpreter. +_SKIPPED_SUFFIXES = (".pyc", ".pyo", ".pyd") + +# Private key material. Credentials reach a container through its environment; +# baking them into an image publishes them to everyone who can pull it. +_CREDENTIAL_SUFFIXES = (".pem", ".key", ".p12", ".pfx") + + +def _sweep_project_files(project_dir): + """Recursively collect (abs_src, rel_dst) for every project file under project_dir, preserving its directory structure. + + The whole project ships, not only its .py files: source also opens PDFs, + prompt text, notes, pyproject.toml, and framework config such as + langgraph.json at runtime, and a file that is absent from the image fails + only once the agent is serving. + + Left behind are hidden files and directories -- which is where .env and + .git live -- the directories the build itself generates, host-local caches + and virtualenvs, private key material, and the few filenames the build + context owns at its root. + """ swept = [] for root, dirs, files in os.walk(project_dir): + at_root = root == project_dir dirs[:] = [ d for d in dirs if not d.startswith(".") - and not (root == project_dir and d in _GENERATED_DIRS) + and d not in _SKIPPED_DIRS + and not d.endswith(".egg-info") + and not (at_root and d in _GENERATED_DIRS) ] for fname in files: abs_src = os.path.join(root, fname) - if fname.endswith(".py") and not os.path.islink(abs_src): - rel_dst = os.path.relpath(abs_src, project_dir) - swept.append((abs_src, rel_dst)) + if fname.startswith(".") or os.path.islink(abs_src): + continue + if fname.endswith(_SKIPPED_SUFFIXES): + continue + rel_dst = os.path.relpath(abs_src, project_dir) + if fname.endswith(_CREDENTIAL_SUFFIXES): + print( + f" Warning: not copying private key material into the image: {rel_dst}" + ) + continue + if at_root and fname in _RESERVED_CONTEXT_NAMES: + print( + f" Warning: the build context owns '{fname}', so the project's " + "own copy is not included in the image" + ) + continue + swept.append((abs_src, rel_dst)) return swept @@ -345,7 +393,7 @@ def generate_docker( output_dir: Optional output directory (default: docker_container//). grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). stub_files: Optional list of agent stub files to copy into the context. - project_dir: Optional project root to sweep for extra .py helper files. + project_dir: Optional project root whose files are swept into the context. stub_entrypoints: Optional {stub_basename: entrypoint} map for exact stub placement. requirements: Optional list of extra pip packages this agent needs. """ @@ -370,10 +418,10 @@ def generate_docker( with open(os.path.join(output_dir, "requirements.txt"), "w") as f: f.write(requirements_txt) - # Sweep the project for extra .py helper files not on the explicit list below. + # Sweep the whole project first; the explicit list below is copied on top. files_to_copy = [] if project_dir: - files_to_copy += _sweep_py_files(project_dir) + files_to_copy += _sweep_project_files(project_dir) # Copy general agent files files_to_copy += [ @@ -472,7 +520,7 @@ def generate_workflow_docker( stub_files: List of stub file paths to include. output_dir: Optional output directory (default: docker_container/Workflow/). grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). - project_dir: Optional project root to sweep for extra .py helper files. + project_dir: Optional project root whose files are swept into the context. stub_entrypoints: Optional {stub_basename: entrypoint} map for exact stub placement. requirements: Optional list of extra pip packages this workflow needs. """ @@ -496,8 +544,8 @@ def generate_workflow_docker( # ---- Copy source files into the build context ------------------------ workflow_basename = os.path.basename(workflow_file) - # Sweep the project for extra .py helper files not on the explicit list below. - files_to_copy = _sweep_py_files(project_dir) if project_dir else [] + # Sweep the whole project first; the explicit list below is copied on top. + files_to_copy = _sweep_project_files(project_dir) if project_dir else [] files_to_copy += [ (os.path.abspath(workflow_file), workflow_basename), From e2156e5701b6a31329e1f2829ca3e43a55c3e5f7 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 18:20:29 -0700 Subject: [PATCH 2/8] [CAN-282] Report what the sweep leaves out, and detect keys by armor Review of the previous commit found three ways it was wrong, plus one hole the end-to-end run exposed. Silent drops. Hidden paths were dropped without a word, which is the same bug CAN-282 exists to fix -- a project keeping runtime assets in .streamlit/ or .prompts/ still failed only once the agent was serving, now with no trace of why. Every exclusion is reported: hidden paths as one aggregated note, private keys and reserved names individually. Name-based key matching was theater. It caught .pem and waved through id_rsa, which has no extension at all. PEM material is now found by its armor whatever the file is called, and a certificate -- public, and sometimes needed -- ships instead of being lumped in with private keys. The docstring says plainly that this is not a secret scanner: credentials.json still ships, because nothing can recognize it. No size signal. Broadening the sweep means a dataset or a virtualenv under a name _SKIPPED_DIRS misses now lands in every image, where before only .py did. Past 100 MB the sweep says how big it got and what the largest file was, which is also the backstop for whatever the hardcoded lists fail to catch. The hole: the build context is assembled inside the project root, so the sweep copied it into itself. `docker_container/` happens to be in _GENERATED_DIRS, so the CLI path was covered by coincidence rather than by construction; any other output directory nested a copy of the context inside itself. The context is now excluded by resolved path. Those lists are hardcoded with no way for a project to override them, which is a consequence of assembling the context by hand instead of letting Docker's own ignore mechanism run. The comment says so. --- tests/test_stub_generator.py | 87 +++++++++++++++++++- ventis/stub_generator.py | 149 ++++++++++++++++++++++++++++------- 2 files changed, 206 insertions(+), 30 deletions(-) diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index 0631cb3..eecac94 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -1,13 +1,16 @@ +import io import os import sys import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path import yaml sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from ventis import stub_generator from ventis.stub_generator import ( BASE_AGENT_REQUIREMENTS, BASE_WORKFLOW_REQUIREMENTS, @@ -97,7 +100,14 @@ class ProjectSweepTests(unittest.TestCase): """The sweep carries the whole project, not only its .py files.""" def _swept(self, project_dir): - return {rel for _, rel in _sweep_project_files(str(project_dir))} + with redirect_stdout(io.StringIO()): + return {rel for _, rel in _sweep_project_files(str(project_dir))} + + def _swept_with_output(self, project_dir): + buffer = io.StringIO() + with redirect_stdout(buffer): + swept = {rel for _, rel in _sweep_project_files(str(project_dir))} + return swept, buffer.getvalue() def test_non_python_files_are_swept_with_their_layout(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -221,6 +231,81 @@ def test_workflow_context_receives_the_swept_project(self): self.assertEqual(copied.read_text(), "{}") + def test_private_keys_are_recognized_by_armor_not_by_name(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "id_rsa", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n") + _write(project / "server.pem", "-----BEGIN RSA PRIVATE KEY-----\nabc\n") + _write(project / "keystore.p12", "binary-ish") + _write(project / "ca.pem", "-----BEGIN CERTIFICATE-----\nabc\n") + _write(project / "notes.key", "this is a text file about keys") + + swept, output = self._swept_with_output(project) + + self.assertEqual(swept, {"ca.pem", "notes.key"}) + self.assertIn("id_rsa", output) + self.assertIn("keystore.p12", output) + + def test_skipped_hidden_paths_are_reported(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "agent.py") + _write(project / ".env", "OPENAI_API_KEY=real") + _write(project / ".streamlit" / "config.toml") + + swept, output = self._swept_with_output(project) + + self.assertEqual(swept, {"agent.py"}) + self.assertIn(".env", output) + self.assertIn(".streamlit", output) + + def test_an_oversized_context_is_reported(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "dataset.bin", "x" * 4096) + _write(project / "agent.py") + + original = stub_generator._LARGE_CONTEXT_BYTES + stub_generator._LARGE_CONTEXT_BYTES = 1024 + try: + swept, output = self._swept_with_output(project) + finally: + stub_generator._LARGE_CONTEXT_BYTES = original + + self.assertEqual(swept, {"dataset.bin", "agent.py"}) + self.assertIn("dataset.bin", output) + + def test_a_normal_project_reports_nothing(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "agent.py") + _write(project / "notes.txt") + _write(project / "__pycache__" / "agent.cpython-311.pyc") + + _, output = self._swept_with_output(project) + + self.assertEqual(output, "") + + def test_the_build_context_is_not_swept_into_itself(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + yaml_path = project / "ExampleAgent.yaml" + yaml_path.write_text(yaml.safe_dump({"agent": {"name": "ExampleAgent"}})) + agent_file = _write(project / "agent.py", "print('ok')\n") + # Not docker_container/, so nothing but exclude_dir keeps this out. + output_dir = project / "build_context" + + generate_docker( + str(yaml_path), + str(agent_file), + output_dir=str(output_dir), + project_dir=str(project), + ) + + nested = list(output_dir.rglob("build_context")) + + self.assertEqual(nested, []) + if __name__ == "__main__": unittest.main() diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 72c262d..a5b0cf1 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -271,6 +271,15 @@ def _format_source(source): return "\n".join(formatted) + "\n" +# What the sweep leaves out of an image. +# +# These lists are a stopgap. The build context is a staging directory assembled +# by hand, so Docker's own ignore mechanism never gets to run and the policy has +# to live here instead -- hardcoded, with no way for a project to override it. +# Keep every entry unambiguous: a wrong guess silently drops real source, and +# there is no lever for the user to put it back. The size warning below is the +# backstop for whatever these lists fail to catch. + # Directories ventis build itself generates inside a project -- never swept. _GENERATED_DIRS = {"docker_container", "stubs", "grpc_stubs"} @@ -290,12 +299,48 @@ def _format_source(source): # Compiled bytecode built against the host interpreter. _SKIPPED_SUFFIXES = (".pyc", ".pyo", ".pyd") -# Private key material. Credentials reach a container through its environment; -# baking them into an image publishes them to everyone who can pull it. -_CREDENTIAL_SUFFIXES = (".pem", ".key", ".p12", ".pfx") +# Binary keystores, which carry no armor to detect them by. +_KEYSTORE_SUFFIXES = (".p12", ".pfx", ".jks") + +# Enough of a file to see PEM armor past any header the tool that wrote it left. +_KEY_ARMOR_SCAN_BYTES = 4096 + +# Past this, say how big the image is getting. It usually means a dataset, a +# checkpoint, or a virtualenv under a name _SKIPPED_DIRS does not know. +_LARGE_CONTEXT_BYTES = 100 * 1024 * 1024 + + +def _looks_like_private_key(path, fname): + """Whether this file is private key material that must not be baked into an image. + + Matching on names is theater -- an OpenSSH key is called `id_rsa`, with no + extension at all -- so PEM material is found by its armor instead, whatever + the file is called. A certificate is public and ships normally; only the + PRIVATE KEY block is held back. + This is not a secret scanner. `credentials.json` and its friends still ship, + because there is no way to recognize them. Credentials belong in the + container environment either way. + """ + if fname.endswith(_KEYSTORE_SUFFIXES): + return True + try: + with open(path, "rb") as handle: + head = handle.read(_KEY_ARMOR_SCAN_BYTES) + except OSError: + return False + return b"-----BEGIN" in head and b"PRIVATE KEY-----" in head + + +def _sample(paths, limit=5): + """A few of these paths, with a count standing in for the rest.""" + shown = ", ".join(sorted(paths)[:limit]) + if len(paths) > limit: + shown += f", (+{len(paths) - limit} more)" + return shown -def _sweep_project_files(project_dir): + +def _sweep_project_files(project_dir, exclude_dir=None): """Recursively collect (abs_src, rel_dst) for every project file under project_dir, preserving its directory structure. The whole project ships, not only its .py files: source also opens PDFs, @@ -303,41 +348,85 @@ def _sweep_project_files(project_dir): langgraph.json at runtime, and a file that is absent from the image fails only once the agent is serving. - Left behind are hidden files and directories -- which is where .env and - .git live -- the directories the build itself generates, host-local caches - and virtualenvs, private key material, and the few filenames the build - context owns at its root. + Left behind are hidden files and directories -- which is where .env and .git + live -- the directories the build itself generates, host-local caches and + virtualenvs, and private key material. Every one of those is reported: a + file that silently fails to arrive is the bug this sweep exists to fix, so + dropping one quietly just moves that bug somewhere harder to find. + + `exclude_dir` is the build context being assembled. It normally sits under + the project root, so without this the sweep copies the context into itself. + Matching is by resolved path, not by name: `_GENERATED_DIRS` happens to cover + the directory the CLI passes, and relying on that coincidence is how this + breaks the first time a caller picks a different output directory. """ swept = [] + hidden = [] + private_keys = [] + reserved = [] + total_bytes = 0 + largest = (0, None) + context_dir = os.path.realpath(exclude_dir) if exclude_dir else None + for root, dirs, files in os.walk(project_dir): at_root = root == project_dir - dirs[:] = [ - d - for d in dirs - if not d.startswith(".") - and d not in _SKIPPED_DIRS - and not d.endswith(".egg-info") - and not (at_root and d in _GENERATED_DIRS) - ] + + kept_dirs = [] + for name in dirs: + if context_dir and os.path.realpath(os.path.join(root, name)) == context_dir: + continue + if name.startswith("."): + hidden.append(os.path.relpath(os.path.join(root, name), project_dir) + os.sep) + elif not ( + name in _SKIPPED_DIRS + or name.endswith(".egg-info") + or (at_root and name in _GENERATED_DIRS) + ): + kept_dirs.append(name) + dirs[:] = kept_dirs + for fname in files: abs_src = os.path.join(root, fname) - if fname.startswith(".") or os.path.islink(abs_src): + rel_dst = os.path.relpath(abs_src, project_dir) + if fname.startswith("."): + hidden.append(rel_dst) continue - if fname.endswith(_SKIPPED_SUFFIXES): + if os.path.islink(abs_src) or fname.endswith(_SKIPPED_SUFFIXES): continue - rel_dst = os.path.relpath(abs_src, project_dir) - if fname.endswith(_CREDENTIAL_SUFFIXES): - print( - f" Warning: not copying private key material into the image: {rel_dst}" - ) + if _looks_like_private_key(abs_src, fname): + private_keys.append(rel_dst) continue if at_root and fname in _RESERVED_CONTEXT_NAMES: - print( - f" Warning: the build context owns '{fname}', so the project's " - "own copy is not included in the image" - ) + reserved.append(rel_dst) continue swept.append((abs_src, rel_dst)) + try: + size = os.path.getsize(abs_src) + except OSError: + size = 0 + total_bytes += size + largest = max(largest, (size, rel_dst)) + + if hidden: + print( + f" Note: {len(hidden)} hidden path(s) not copied into the image: " + f"{_sample(hidden)}. Move anything the agent opens at runtime out of " + f"a dotted path." + ) + for rel_dst in sorted(private_keys): + print(f" Warning: not copying private key material into the image: {rel_dst}") + for rel_dst in sorted(reserved): + print( + f" Warning: the build context owns '{os.path.basename(rel_dst)}', so the " + f"project's own copy is not included in the image" + ) + if total_bytes > _LARGE_CONTEXT_BYTES: + print( + f" Warning: sweeping {total_bytes // (1024 * 1024)} MB into this image, " + f"largest is {largest[1]} at {largest[0] // (1024 * 1024)} MB. Everything " + f"under the project root ships unless it is hidden or generated." + ) + return swept @@ -421,7 +510,7 @@ def generate_docker( # Sweep the whole project first; the explicit list below is copied on top. files_to_copy = [] if project_dir: - files_to_copy += _sweep_project_files(project_dir) + files_to_copy += _sweep_project_files(project_dir, exclude_dir=output_dir) # Copy general agent files files_to_copy += [ @@ -545,7 +634,9 @@ def generate_workflow_docker( workflow_basename = os.path.basename(workflow_file) # Sweep the whole project first; the explicit list below is copied on top. - files_to_copy = _sweep_project_files(project_dir) if project_dir else [] + files_to_copy = ( + _sweep_project_files(project_dir, exclude_dir=output_dir) if project_dir else [] + ) files_to_copy += [ (os.path.abspath(workflow_file), workflow_basename), From 7a8a319c24cf514817fe2814f54547417e567fc3 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 18:24:10 -0700 Subject: [PATCH 3/8] [CAN-282] Cut the sweep comments to what the code cannot say Restating a constant's contents in prose above it is noise. What stays is the part the code cannot state: why the lists are hardcoded with no override, why requirements.txt is reserved, why keys are matched by armor and not by name, and why exclude_dir is compared by resolved path. --- ventis/stub_generator.py | 67 ++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index a5b0cf1..9b9ef0b 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -271,42 +271,34 @@ def _format_source(source): return "\n".join(formatted) + "\n" -# What the sweep leaves out of an image. -# -# These lists are a stopgap. The build context is a staging directory assembled -# by hand, so Docker's own ignore mechanism never gets to run and the policy has -# to live here instead -- hardcoded, with no way for a project to override it. -# Keep every entry unambiguous: a wrong guess silently drops real source, and -# there is no lever for the user to put it back. The size warning below is the -# backstop for whatever these lists fail to catch. +# What the sweep leaves out. These lists are hardcoded with no project override +# because the build context is assembled by hand, so Docker's own ignore +# mechanism never gets to run. # Directories ventis build itself generates inside a project -- never swept. _GENERATED_DIRS = {"docker_container", "stubs", "grpc_stubs"} -# Host-local noise: caches, virtualenvs, and vendored dependency trees. The -# image installs its own dependencies for its own platform, so these are at -# best dead weight and at worst the wrong architecture. +# A macOS virtualenv or cache is dead weight in a linux image, at best. _SKIPPED_DIRS = {"__pycache__", "node_modules", "venv", "site-packages"} -# Filenames the generator writes into the build context itself. A project file -# of the same name at the root would collide with the generated one. +# The generator writes these into the context itself, requirements.txt before +# the copy runs -- a project file of the same name at the root would win. _RESERVED_CONTEXT_NAMES = { "Dockerfile", "requirements.txt", "workflow_launcher.py", } -# Compiled bytecode built against the host interpreter. _SKIPPED_SUFFIXES = (".pyc", ".pyo", ".pyd") -# Binary keystores, which carry no armor to detect them by. +# Binary keystores carry no armor to detect them by. _KEYSTORE_SUFFIXES = (".p12", ".pfx", ".jks") -# Enough of a file to see PEM armor past any header the tool that wrote it left. +# Enough to see PEM armor past any header the tool that wrote it left. _KEY_ARMOR_SCAN_BYTES = 4096 -# Past this, say how big the image is getting. It usually means a dataset, a -# checkpoint, or a virtualenv under a name _SKIPPED_DIRS does not know. +# Past this, say so: usually a dataset, a checkpoint, or a virtualenv under a +# name _SKIPPED_DIRS does not know. _LARGE_CONTEXT_BYTES = 100 * 1024 * 1024 @@ -314,13 +306,11 @@ def _looks_like_private_key(path, fname): """Whether this file is private key material that must not be baked into an image. Matching on names is theater -- an OpenSSH key is called `id_rsa`, with no - extension at all -- so PEM material is found by its armor instead, whatever - the file is called. A certificate is public and ships normally; only the - PRIVATE KEY block is held back. + extension at all -- so PEM material is found by its armor instead. A + certificate is public and ships; only a PRIVATE KEY block is held back. - This is not a secret scanner. `credentials.json` and its friends still ship, - because there is no way to recognize them. Credentials belong in the - container environment either way. + Not a secret scanner: `credentials.json` still ships, because nothing can + recognize it. Credentials belong in the container environment either way. """ if fname.endswith(_KEYSTORE_SUFFIXES): return True @@ -333,7 +323,7 @@ def _looks_like_private_key(path, fname): def _sample(paths, limit=5): - """A few of these paths, with a count standing in for the rest.""" + """Up to `limit` of these paths, with a count standing in for the rest.""" shown = ", ".join(sorted(paths)[:limit]) if len(paths) > limit: shown += f", (+{len(paths) - limit} more)" @@ -343,22 +333,17 @@ def _sample(paths, limit=5): def _sweep_project_files(project_dir, exclude_dir=None): """Recursively collect (abs_src, rel_dst) for every project file under project_dir, preserving its directory structure. - The whole project ships, not only its .py files: source also opens PDFs, - prompt text, notes, pyproject.toml, and framework config such as - langgraph.json at runtime, and a file that is absent from the image fails - only once the agent is serving. - - Left behind are hidden files and directories -- which is where .env and .git - live -- the directories the build itself generates, host-local caches and - virtualenvs, and private key material. Every one of those is reported: a - file that silently fails to arrive is the bug this sweep exists to fix, so - dropping one quietly just moves that bug somewhere harder to find. - - `exclude_dir` is the build context being assembled. It normally sits under - the project root, so without this the sweep copies the context into itself. - Matching is by resolved path, not by name: `_GENERATED_DIRS` happens to cover - the directory the CLI passes, and relying on that coincidence is how this - breaks the first time a caller picks a different output directory. + Not only .py: source opens PDFs, prompts, and framework config at runtime, + and a file missing from the image fails only once the agent is serving. + + Every exclusion is reported. A file that silently fails to arrive is the bug + this sweep exists to fix, so dropping one quietly just moves it somewhere + harder to find. + + `exclude_dir` is the build context being assembled, which sits under the + project root. Matching it by resolved path rather than name is what keeps + this working for a caller that picks an output directory outside + `_GENERATED_DIRS`. """ swept = [] hidden = [] From fc20a71adfc70136143cd3ab9f4302ef194fd6dc Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 19:45:43 -0700 Subject: [PATCH 4/8] [CAN-282] Fix a crash on empty files, and stop the note crying wolf largest = max(largest, (size, rel_dst)) compares tuples, so two files of equal size fall through to comparing their paths -- and the initial (0, None) meets the first zero-byte file the sweep finds. An empty __init__.py is in nearly every Python project, so this took the build down with a TypeError. Every test passed because the test helper writes one byte by default. Compare the size and nothing else. The hidden-path note fired on .git, .gitignore, .venv and every tool cache, which is to say on every real build. A note that always fires is wallpaper, and it takes the one that matters -- a project keeping prompts in .prompts/ -- down with it. Ordinary repo furniture is still held back, just no longer announced. A wrong guess in that list costs a line of output rather than a missing file, which is why it sits apart from the lists that decide what ships. --- tests/test_stub_generator.py | 29 +++++++++++++++++++++++++++++ ventis/stub_generator.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index eecac94..3907285 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -306,6 +306,35 @@ def test_the_build_context_is_not_swept_into_itself(self): self.assertEqual(nested, []) + def test_an_empty_file_does_not_break_the_sweep(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + # An empty __init__.py is in nearly every Python project, and it + # used to make the largest-file bookkeeping compare a path to None. + _write(project / "src" / "__init__.py", "") + _write(project / "src" / "agent.py", "print('ok')\n") + + swept = self._swept(project) + + self.assertEqual( + swept, + {os.path.join("src", "__init__.py"), os.path.join("src", "agent.py")}, + ) + + def test_ordinary_repo_furniture_is_not_reported(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "agent.py") + _write(project / ".gitignore", "*.pyc") + _write(project / ".git" / "config") + _write(project / ".venv" / "pyvenv.cfg") + _write(project / ".mypy_cache" / "cache.json") + + swept, output = self._swept_with_output(project) + + self.assertEqual(swept, {"agent.py"}) + self.assertEqual(output, "") + if __name__ == "__main__": unittest.main() diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 9b9ef0b..3118064 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -301,6 +301,29 @@ def _format_source(source): # name _SKIPPED_DIRS does not know. _LARGE_CONTEXT_BYTES = 100 * 1024 * 1024 +# Hidden paths every repo has. Held back like all hidden paths, but not worth +# saying so on every build: a note that always fires is wallpaper, and it takes +# the one that matters down with it. Guessing wrong here costs a line of output, +# not a missing file -- which is why the guess lives here and not above. +_UNREMARKABLE_HIDDEN = { + ".git", + ".gitignore", + ".gitattributes", + ".gitmodules", + ".dockerignore", + ".editorconfig", + ".python-version", + ".venv", + ".idea", + ".vscode", + ".DS_Store", +} + + +def _worth_reporting(name): + """Whether a hidden path is project content rather than tooling furniture.""" + return name not in _UNREMARKABLE_HIDDEN and not name.endswith("_cache") + def _looks_like_private_key(path, fname): """Whether this file is private key material that must not be baked into an image. @@ -361,7 +384,10 @@ def _sweep_project_files(project_dir, exclude_dir=None): if context_dir and os.path.realpath(os.path.join(root, name)) == context_dir: continue if name.startswith("."): - hidden.append(os.path.relpath(os.path.join(root, name), project_dir) + os.sep) + if _worth_reporting(name): + hidden.append( + os.path.relpath(os.path.join(root, name), project_dir) + os.sep + ) elif not ( name in _SKIPPED_DIRS or name.endswith(".egg-info") @@ -374,7 +400,8 @@ def _sweep_project_files(project_dir, exclude_dir=None): abs_src = os.path.join(root, fname) rel_dst = os.path.relpath(abs_src, project_dir) if fname.startswith("."): - hidden.append(rel_dst) + if _worth_reporting(fname): + hidden.append(rel_dst) continue if os.path.islink(abs_src) or fname.endswith(_SKIPPED_SUFFIXES): continue @@ -390,7 +417,8 @@ def _sweep_project_files(project_dir, exclude_dir=None): except OSError: size = 0 total_bytes += size - largest = max(largest, (size, rel_dst)) + if size > largest[0]: + largest = (size, rel_dst) if hidden: print( From b0e7a86f335571d9755a04f2adb81584dfe9f2d6 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 19:46:45 -0700 Subject: [PATCH 5/8] [CAN-282] Report host-local directories, which used to ship their .py The old sweep pruned only hidden directories, so a .py file under venv/, node_modules/, site-packages/ or an .egg-info/ did reach the image. _SKIPPED_DIRS drops those directories entirely, which is right, but doing it silently is a behavior change nobody can see -- the same silent-drop bug this ticket exists to fix. They are now reported. __pycache__ stays silent because it never held anything shippable, which leaves the invariant clean: the only quiet drops are bytecode, symlinks, __pycache__, and the build's own output, and not one of those could ever have shipped. --- tests/test_stub_generator.py | 17 +++++++++++++++++ ventis/stub_generator.py | 27 ++++++++++++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index 3907285..071a964 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -335,6 +335,23 @@ def test_ordinary_repo_furniture_is_not_reported(self): self.assertEqual(swept, {"agent.py"}) self.assertEqual(output, "") + def test_host_local_directories_are_reported_because_they_used_to_ship(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = Path(tmpdir) + _write(project / "agent.py") + # These held .py files that the old .py-only sweep shipped, so + # dropping them is a behavior change and has to be visible. + _write(project / "venv" / "lib" / "site.py") + _write(project / "proj.egg-info" / "PKG-INFO") + _write(project / "__pycache__" / "agent.cpython-311.pyc") + + swept, output = self._swept_with_output(project) + + self.assertEqual(swept, {"agent.py"}) + self.assertIn("venv", output) + self.assertIn("proj.egg-info", output) + self.assertNotIn("__pycache__", output) + if __name__ == "__main__": unittest.main() diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 3118064..8893e34 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -359,9 +359,10 @@ def _sweep_project_files(project_dir, exclude_dir=None): Not only .py: source opens PDFs, prompts, and framework config at runtime, and a file missing from the image fails only once the agent is serving. - Every exclusion is reported. A file that silently fails to arrive is the bug - this sweep exists to fix, so dropping one quietly just moves it somewhere - harder to find. + Every exclusion is reported except the ones that could never have held + shippable content -- __pycache__, bytecode, symlinks, and the build's own + output. A file that silently fails to arrive is the bug this sweep exists to + fix, so dropping one quietly just moves it somewhere harder to find. `exclude_dir` is the build context being assembled, which sits under the project root. Matching it by resolved path rather than name is what keeps @@ -370,6 +371,7 @@ def _sweep_project_files(project_dir, exclude_dir=None): """ swept = [] hidden = [] + host_local = [] private_keys = [] reserved = [] total_bytes = 0 @@ -383,16 +385,14 @@ def _sweep_project_files(project_dir, exclude_dir=None): for name in dirs: if context_dir and os.path.realpath(os.path.join(root, name)) == context_dir: continue + rel_dir = os.path.relpath(os.path.join(root, name), project_dir) + os.sep if name.startswith("."): if _worth_reporting(name): - hidden.append( - os.path.relpath(os.path.join(root, name), project_dir) + os.sep - ) - elif not ( - name in _SKIPPED_DIRS - or name.endswith(".egg-info") - or (at_root and name in _GENERATED_DIRS) - ): + hidden.append(rel_dir) + elif name in _SKIPPED_DIRS or name.endswith(".egg-info"): + if name != "__pycache__": + host_local.append(rel_dir) + elif not (at_root and name in _GENERATED_DIRS): kept_dirs.append(name) dirs[:] = kept_dirs @@ -426,6 +426,11 @@ def _sweep_project_files(project_dir, exclude_dir=None): f"{_sample(hidden)}. Move anything the agent opens at runtime out of " f"a dotted path." ) + if host_local: + print( + f" Note: {len(host_local)} host-local path(s) not copied into the image: " + f"{_sample(host_local)}. The image installs its own dependencies." + ) for rel_dst in sorted(private_keys): print(f" Warning: not copying private key material into the image: {rel_dst}") for rel_dst in sorted(reserved): From acd5e4da0c4f33d7777ff9e99f427aa5ff515e07 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 20:06:54 -0700 Subject: [PATCH 6/8] [CAN-282] Prune symlinks explicitly, in both cases, and say so Symlinked files were already skipped. Symlinked directories were skipped too, but only because os.walk defaults to followlinks=False -- a security property resting on a default someone can flip, and one that cannot report what it skipped. Both cases are now pruned by the same rule in the same function: a link to /etc or to a home directory would copy files from outside the project into the image. They are reported rather than dropped quietly, because the target may be a shared config a monorepo expects in the image. The answer there is to copy the target in, not to follow the link. No new constant and no new knob: one predicate, two loops, one note. --- tests/test_stub_generator.py | 11 ++++++++--- ventis/stub_generator.py | 27 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index 071a964..f6b989b 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -163,15 +163,20 @@ def test_generated_directory_names_are_only_reserved_at_the_root(self): self.assertEqual(swept, {os.path.join("src", "stubs", "handwritten.py")}) - def test_symlinks_are_not_followed(self): + def test_symlinks_are_not_followed_and_are_reported(self): with tempfile.TemporaryDirectory() as tmpdir: - project = Path(tmpdir) + outside = Path(tmpdir) / "outside" + _write(outside / "secret.txt", "not ours") + project = Path(tmpdir) / "project" _write(project / "real.txt") (project / "link.txt").symlink_to(project / "real.txt") + (project / "escape").symlink_to(outside) - swept = self._swept(project) + swept, output = self._swept_with_output(project) self.assertEqual(swept, {"real.txt"}) + self.assertIn("link.txt", output) + self.assertIn("escape", output) def test_project_requirements_does_not_replace_the_generated_one(self): with tempfile.TemporaryDirectory() as tmpdir: diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 8893e34..b576e89 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -359,10 +359,16 @@ def _sweep_project_files(project_dir, exclude_dir=None): Not only .py: source opens PDFs, prompts, and framework config at runtime, and a file missing from the image fails only once the agent is serving. + Symlinks are never followed. A link to /etc or to a home directory would + copy files from outside the project into the image, so both the file and the + directory case are pruned here rather than left to `os.walk` defaulting to + `followlinks=False` -- a security property should not rest on a default + someone can flip. + Every exclusion is reported except the ones that could never have held - shippable content -- __pycache__, bytecode, symlinks, and the build's own - output. A file that silently fails to arrive is the bug this sweep exists to - fix, so dropping one quietly just moves it somewhere harder to find. + shippable content -- __pycache__, bytecode, and the build's own output. A + file that silently fails to arrive is the bug this sweep exists to fix, so + dropping one quietly just moves it somewhere harder to find. `exclude_dir` is the build context being assembled, which sits under the project root. Matching it by resolved path rather than name is what keeps @@ -371,6 +377,7 @@ def _sweep_project_files(project_dir, exclude_dir=None): """ swept = [] hidden = [] + symlinks = [] host_local = [] private_keys = [] reserved = [] @@ -386,7 +393,9 @@ def _sweep_project_files(project_dir, exclude_dir=None): if context_dir and os.path.realpath(os.path.join(root, name)) == context_dir: continue rel_dir = os.path.relpath(os.path.join(root, name), project_dir) + os.sep - if name.startswith("."): + if os.path.islink(os.path.join(root, name)): + symlinks.append(rel_dir) + elif name.startswith("."): if _worth_reporting(name): hidden.append(rel_dir) elif name in _SKIPPED_DIRS or name.endswith(".egg-info"): @@ -403,7 +412,10 @@ def _sweep_project_files(project_dir, exclude_dir=None): if _worth_reporting(fname): hidden.append(rel_dst) continue - if os.path.islink(abs_src) or fname.endswith(_SKIPPED_SUFFIXES): + if os.path.islink(abs_src): + symlinks.append(rel_dst) + continue + if fname.endswith(_SKIPPED_SUFFIXES): continue if _looks_like_private_key(abs_src, fname): private_keys.append(rel_dst) @@ -426,6 +438,11 @@ def _sweep_project_files(project_dir, exclude_dir=None): f"{_sample(hidden)}. Move anything the agent opens at runtime out of " f"a dotted path." ) + if symlinks: + print( + f" Note: {len(symlinks)} symlink(s) not followed into the image: " + f"{_sample(symlinks)}. Copy the target in if the agent needs it." + ) if host_local: print( f" Note: {len(host_local)} host-local path(s) not copied into the image: " From fda6da05376b7a4cb712afc995c66e4ecc9f0206 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 1 Sep 2026 20:13:53 -0700 Subject: [PATCH 7/8] [CAN-282] Hoist the joined path, which also fits ruff's line length The directory loop built os.path.join(root, name) three times and the first one ran to 89 characters, the one formatting deviation this branch added to an already-unformatted file. One local name fixes both. --- ventis/stub_generator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index b576e89..e32310b 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -390,10 +390,11 @@ def _sweep_project_files(project_dir, exclude_dir=None): kept_dirs = [] for name in dirs: - if context_dir and os.path.realpath(os.path.join(root, name)) == context_dir: + abs_dir = os.path.join(root, name) + if context_dir and os.path.realpath(abs_dir) == context_dir: continue - rel_dir = os.path.relpath(os.path.join(root, name), project_dir) + os.sep - if os.path.islink(os.path.join(root, name)): + rel_dir = os.path.relpath(abs_dir, project_dir) + os.sep + if os.path.islink(abs_dir): symlinks.append(rel_dir) elif name.startswith("."): if _worth_reporting(name): From 059f9399b4867d2c3e19e878f079bc23239cae97 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Thu, 3 Sep 2026 17:33:15 -0700 Subject: [PATCH 8/8] improve the func name --- ventis/stub_generator.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index e32310b..cc8f55b 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -320,9 +320,9 @@ def _format_source(source): } -def _worth_reporting(name): - """Whether a hidden path is project content rather than tooling furniture.""" - return name not in _UNREMARKABLE_HIDDEN and not name.endswith("_cache") +def _is_unremarkable_hidden(name): + """Whether this hidden path is the tooling furniture every repo has, rather than project content.""" + return name in _UNREMARKABLE_HIDDEN or name.endswith("_cache") def _looks_like_private_key(path, fname): @@ -345,8 +345,8 @@ def _looks_like_private_key(path, fname): return b"-----BEGIN" in head and b"PRIVATE KEY-----" in head -def _sample(paths, limit=5): - """Up to `limit` of these paths, with a count standing in for the rest.""" +def _format_path_sample(paths, limit=5): + """One line naming up to `limit` of these paths, with a count standing in for the rest.""" shown = ", ".join(sorted(paths)[:limit]) if len(paths) > limit: shown += f", (+{len(paths) - limit} more)" @@ -397,7 +397,7 @@ def _sweep_project_files(project_dir, exclude_dir=None): if os.path.islink(abs_dir): symlinks.append(rel_dir) elif name.startswith("."): - if _worth_reporting(name): + if not _is_unremarkable_hidden(name): hidden.append(rel_dir) elif name in _SKIPPED_DIRS or name.endswith(".egg-info"): if name != "__pycache__": @@ -410,7 +410,7 @@ def _sweep_project_files(project_dir, exclude_dir=None): abs_src = os.path.join(root, fname) rel_dst = os.path.relpath(abs_src, project_dir) if fname.startswith("."): - if _worth_reporting(fname): + if not _is_unremarkable_hidden(fname): hidden.append(rel_dst) continue if os.path.islink(abs_src): @@ -436,18 +436,18 @@ def _sweep_project_files(project_dir, exclude_dir=None): if hidden: print( f" Note: {len(hidden)} hidden path(s) not copied into the image: " - f"{_sample(hidden)}. Move anything the agent opens at runtime out of " + f"{_format_path_sample(hidden)}. Move anything the agent opens at runtime out of " f"a dotted path." ) if symlinks: print( f" Note: {len(symlinks)} symlink(s) not followed into the image: " - f"{_sample(symlinks)}. Copy the target in if the agent needs it." + f"{_format_path_sample(symlinks)}. Copy the target in if the agent needs it." ) if host_local: print( f" Note: {len(host_local)} host-local path(s) not copied into the image: " - f"{_sample(host_local)}. The image installs its own dependencies." + f"{_format_path_sample(host_local)}. The image installs its own dependencies." ) for rel_dst in sorted(private_keys): print(f" Warning: not copying private key material into the image: {rel_dst}")