From 76b5a455ab87a6de2107602fcf7dd7a65fd2dc5d Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Thu, 10 Sep 2026 10:59:18 -0700 Subject: [PATCH 1/3] Fix stale capability probes and document otel config in porting-to-canyonos skill V030/V031 gated on canyonos_core capabilities that never actually vary: env_file injection is now unconditional in global_controller.py, and editable_install (_install_step) has never existed in canyonos_core's history. Treat both as fixed facts instead of probing for them, and document the otel.destinations key that global_controller.yaml already supports but the skill never mentioned. Co-Authored-By: Claude Sonnet 5 --- .../porting-to-canyonos/references/ec2.md | 5 +- .../references/manifest.md | 33 +++++- .../references/validation-and-deploy.md | 8 ++ .../skills/porting-to-canyonos/validate.py | 52 ++++++--- .../validation/packaging.py | 101 +++++------------- .../porting-to-canyonos/validation/runtime.py | 30 +++--- 6 files changed, 122 insertions(+), 107 deletions(-) diff --git a/.claude/skills/porting-to-canyonos/references/ec2.md b/.claude/skills/porting-to-canyonos/references/ec2.md index 6dbdfe2e..d5f3390d 100644 --- a/.claude/skills/porting-to-canyonos/references/ec2.md +++ b/.claude/skills/porting-to-canyonos/references/ec2.md @@ -31,8 +31,9 @@ that provisioning, SSH, image transfer, or remote container startup works. ## Networking A remote container's `host.docker.internal` names its own EC2 Docker host. It -does not name the local controller machine. Databases, model proxies, and other -services must use addresses reachable from every selected host. +does not name the local controller machine. Databases, model proxies, `otel` +destinations, and other services must use addresses reachable from every +selected host. The environment file may be copied temporarily to a remote host by runtimes that expose the `env_file` capability. Confirm behavior from the capability probe and diff --git a/.claude/skills/porting-to-canyonos/references/manifest.md b/.claude/skills/porting-to-canyonos/references/manifest.md index 0eb00acd..a2520888 100644 --- a/.claude/skills/porting-to-canyonos/references/manifest.md +++ b/.claude/skills/porting-to-canyonos/references/manifest.md @@ -46,9 +46,10 @@ column only, in one round, carrying these defaults. | `redis_port`, `redis.host` / `.port` / `.db` | developer | `6379`, `localhost` / `6379` / `0` | | `poll_interval` | developer | `5` | | `env_file` | developer — the file's location and whether it exists | `.env` when the survey found credential reads, else absent | +| `otel.destinations` | derived for `provider: local` (see below) | the local dashboard's OTLP ingest | | `policy.yaml` | developer | absent | -Two entries in that table are not free choices, and saying so is part of showing +Three entries in that table are not free choices, and saying so is part of showing the config rather than asking about it: - **`replicas` stops being a choice once a service holds cross-request state.** @@ -59,6 +60,26 @@ the config rather than asking about it: example environment, and a wrong AMI, subnet, or security group fails at deploy preflight or, worse, provisions something unreachable. Unanswered means the entry stays `local`. +- **`otel.destinations` defaults to the local dashboard's own OTLP ingest for + `provider: local`.** Without it, the exporter subprocess never starts and no + trace reaches the dashboard -- expected only when the developer explicitly + wants tracing off. Include: + + ```yaml + otel: + # The dashboard api's own OTLP ingest. Must be the full url including the + # path: the http exporter uses an explicitly-passed endpoint verbatim and + # only appends /v1/traces when reading OTEL_EXPORTER_OTLP_ENDPOINT. + destinations: + - name: local + protocol: http + endpoint: http://host.docker.internal:3000/v1/traces + headers: {} + ``` + + `host.docker.internal` names the local Docker host, not a remote one -- + read [ec2.md](ec2.md#networking) before reusing this block on an entry with + `provider: EC2`. ## Configuration review @@ -183,6 +204,16 @@ redis: db: 0 env_file: .env # relative to the application root, not .car + +otel: + # The dashboard api's own OTLP ingest. Must be the full url including the + # path: the http exporter uses an explicitly-passed endpoint verbatim and + # only appends /v1/traces when reading OTEL_EXPORTER_OTLP_ENDPOINT. + destinations: + - name: local + protocol: http + endpoint: http://host.docker.internal:3000/v1/traces + headers: {} ``` Omit `database`. Without it every metrics poll logs `Could not parse SQLAlchemy diff --git a/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md b/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md index 9f5f5157..6b2e16f7 100644 --- a/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md +++ b/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md @@ -37,6 +37,14 @@ capability limitations: list each in the handoff and state whether it blocks this source. Confirm with `git status` that no developer-owned file outside `.car` changed. +`canyonos_core` ships only inside the built container image, so any check +still gated on importing it (currently only the full-project-file-sweep +check) reports UNAVAILABLE on every local run, on every machine, regardless +of Python or venv. That is expected -- report it as such in the handoff and +move on. Do not treat it as a code defect or an environment problem to debug +on this host; there is no local fix, and no amount of venv or `PYTHONPATH` +troubleshooting makes it importable outside a container. + Report: - that the `.car` port validated; diff --git a/.claude/skills/porting-to-canyonos/validate.py b/.claude/skills/porting-to-canyonos/validate.py index 9283dc9f..868ee811 100755 --- a/.claude/skills/porting-to-canyonos/validate.py +++ b/.claude/skills/porting-to-canyonos/validate.py @@ -15,9 +15,12 @@ Exit 1 if any ERROR was reported, 0 otherwise. --strict also fails on warnings. -Runtime capabilities vary across CanyonOS Core installations. This script probes -the importable `canyonos_core` package directly. A capability-gated check reports -UNAVAILABLE when its behavior cannot be proven. +`canyonos_core` ships inside the built container image, not on the host: the +`canyonos` CLI's own venv does not install it, so this script's probe of the +importable `canyonos_core` package fails on every local run, for every source +tree, regardless of which Python or venv runs it. That is expected, not an +environment defect to chase on this machine. A capability-gated check reports +UNAVAILABLE rather than failing when its behavior cannot be proven this way. """ import argparse @@ -127,14 +130,6 @@ def validate(artifact_dir, config_path, capabilities): entrypoint_path = os.path.join(source_dir, entrypoint or "") if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): check_entrypoint_module(report, source_dir, name, entrypoint) - check_requirements_coverage( - report, - source_dir, - entry, - entrypoint_path, - config_path, - BASE_AGENT_REQUIREMENTS, - ) # Where each agent's stub is written, and therefore the only import that # reaches it over gRPC. @@ -149,6 +144,35 @@ def validate(artifact_dir, config_path, capabilities): if name in agents_by_name ] + # A second pass: every other agent's entrypoint is a stub in this image, + # but this entry's own entrypoint is the one file that is not -- it is + # the real code this image runs. Excluding it from `shadowed_paths` is + # what tells `reachable_imports` to keep walking past it instead of + # treating it as a stub and losing everything it reaches. + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": + continue + name = entry.get("name") + entrypoint = entry.get("entrypoint") + entrypoint_path = os.path.join(source_dir, entrypoint or "") + if not (isinstance(entrypoint, str) and os.path.isfile(entrypoint_path)): + continue + own_path = os.path.realpath(entrypoint_path) + shadowed_paths = [ + path + for path in stubbed_entrypoint_paths + if os.path.realpath(path) != own_path + ] + check_requirements_coverage( + report, + source_dir, + entry, + entrypoint_path, + config_path, + BASE_AGENT_REQUIREMENTS, + shadowed_paths=shadowed_paths, + ) + for entry in entries: if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": continue @@ -174,7 +198,7 @@ def validate(artifact_dir, config_path, capabilities): # These survive a green build and otherwise surface only in a container or # on its first request. check_flat_collisions(report, source_dir, entrypoints) - check_env_file(report, config, config_path, artifact_dir) + check_env_file(report, config, config_path) entrypoint_paths = [ os.path.join(source_dir, e) @@ -222,7 +246,9 @@ def _wrap(text, width, indent): def print_report(report, artifact_root): caps = report.capabilities if not caps.get("canyonos_core"): - print("canyonos_core is not importable here -- capability-gated rules are") + print("canyonos_core is not importable here -- expected on a local run,") + print("since it ships only inside the built container image. This is not") + print("something to fix on this machine. Capability-gated rules are") print("reported UNAVAILABLE rather than checked.\n") else: print("CanyonOS Core capabilities detected:") diff --git a/.claude/skills/porting-to-canyonos/validation/packaging.py b/.claude/skills/porting-to-canyonos/validation/packaging.py index a89d5a7f..8d37495c 100644 --- a/.claude/skills/porting-to-canyonos/validation/packaging.py +++ b/.claude/skills/porting-to-canyonos/validation/packaging.py @@ -1,6 +1,12 @@ -"""V030-V031 -- capability-gated rules about credentials and import roots.""" +"""V030-V031 -- rules about credentials and import roots. -import os +Both used to be gated on a probed capability. Neither actually varies: +`global_controller.py` calls `resolve_env_file` unconditionally on every +deployment, so env-file injection is not optional; and `canyonos_core` has no +`_install_step` or any other editable-install mechanism, in this codebase or +its history, so an editable install is never available. Treat both as fixed +facts about the current runtime instead of probing for them. +""" from validation.core import line_of from validation.python_source import ( @@ -12,53 +18,24 @@ from validation.runtime import RUNTIME_FLAT_NAMES -def check_env_file(report, config, config_path, artifact_dir): - """V030 -- gated on detected env-file injection support.""" - declared = config.get("env_file") - supported = report.capabilities.get("env_file") - - if not supported: - if declared: - report.error( - "V030", - config_path, - line_of(config, "env_file"), - f"`env_file: {declared}` is set, but this CanyonOS Core never reads it", - "No resolve_env_file in the importable canyonos_core package, so the " - "key is silently dropped and the container answers a provider " - "credential error on the first request. This port requires the " - "`env_file` runtime capability.", - ) - else: - report.unavailable( - "V030", - "env_file is not supported by the importable `canyonos_core` runtime. " - "Credentials have no declared path into a container on this tree.", - ) +def check_env_file(report, config, config_path): + """V030 -- env-file injection is mandatory; warn when the config omits it.""" + if config.get("env_file"): return - if not declared: - report.warn( - "V030", - config_path, - line_of(config), - "no `env_file:` in the config", - "Only runtime-managed CANYONOS_* variables are guaranteed without it. " - "If the source reads credentials from the environment, the first " - "request fails on a provider error.", - ) - return + report.warn( + "V030", + config_path, + line_of(config), + "no `env_file:` in the config", + "Only runtime-managed CANYONOS_* variables are guaranteed without it. " + "If the source reads credentials from the environment, the first " + "request fails on a provider error.", + ) def check_import_root(report, source_dir, entrypoint_paths): - """V031 -- gated on detected editable-install support.""" - supported = report.capabilities.get("editable_install") - has_metadata = any( - os.path.isfile(os.path.join(source_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - - non_flat = [] + """V031 -- canyonos_core runs no editable install; only /app-rooted names import.""" for path in entrypoint_paths: tree, _ = parse_python(path) if tree is None: @@ -69,40 +46,16 @@ def check_import_root(report, source_dir, entrypoint_paths): if resolves_flat(source_dir, name): continue location = resolves_nested(source_dir, name) - if location: - non_flat.append((path, lineno, name, location)) - - if not supported: - report.unavailable( - "V031", - "the editable install (`-e .`) is not supported by the importable " - "`canyonos_core` runtime. Only names rooted at /app import inside a container.", - ) - for path, lineno, name, location in non_flat: + if not location: + continue report.error( "V031", path, lineno, f"`import {name}` resolves to {location}, which is not at the " "root of the source copy", - "sys.path[0] is /app and this CanyonOS Core runs no editable install, " - "so only modules swept to the root import. The adapter raises " - "ModuleNotFoundError inside _load_agent and the first request " - "answers 'No agent loaded'.", - ) - return - - if non_flat and not has_metadata: - for path, lineno, name, location in non_flat: - report.error( - "V031", - path, - lineno, - f"`import {name}` resolves to {location}, and the source copy's " - "root has no packaging metadata", - "A pyproject.toml, setup.py or setup.cfg at the root of the " - "source copy is what adds `-e .`; metadata nested deeper in the " - "tree is ignored. Add minimal root metadata pointing at the " - "existing package directory. Without it the install is skipped " - "silently.", + "sys.path[0] is /app and canyonos_core runs no editable " + "install, so only modules swept to the root import. The " + "adapter raises ModuleNotFoundError inside _load_agent and " + "the first request answers 'No agent loaded'.", ) diff --git a/.claude/skills/porting-to-canyonos/validation/runtime.py b/.claude/skills/porting-to-canyonos/validation/runtime.py index 3c76aa85..c9cac739 100644 --- a/.claude/skills/porting-to-canyonos/validation/runtime.py +++ b/.claude/skills/porting-to-canyonos/validation/runtime.py @@ -1,6 +1,5 @@ """Runtime capabilities and dependency facts used by validation checks.""" -import importlib import os import sys @@ -43,8 +42,6 @@ NAMESPACE_DISTRIBUTIONS = {"llama_index": "llama-index"} CAPABILITY_SOURCE = { - "env_file": "runtime env-file injection", - "editable_install": "editable project installation", "sweeps_all_files": "full project-file sweep", } @@ -94,7 +91,19 @@ def _stdlib_names(): def probe_capabilities(): - """Probe the installed compatibility runtime behind the CanyonOS CLI.""" + """Probe for `canyonos_core`, which is never present on the local host. + + It ships only inside the built container image, not in the `canyonos` CLI's + own venv or system Python, so this always returns all-False when run + outside a container -- on every machine, for every source tree. That is + the expected result of a local run, not a broken install to fix. + + `env_file` and `editable_install` used to be probed here too. Neither + actually varies, so they are no longer treated as capabilities: + `resolve_env_file` is called unconditionally by every + `global_controller.py`, and `canyonos_core` has no `_install_step` or any + other editable-install mechanism, in this codebase or its history. + """ capabilities = dict.fromkeys(CAPABILITY_SOURCE, False) capabilities["canyonos_core"] = False try: @@ -103,18 +112,5 @@ def probe_capabilities(): return capabilities capabilities["canyonos_core"] = True - capabilities["editable_install"] = hasattr(stub_generator, "_install_step") capabilities["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") - - for module_name in ( - "canyonos_core.controller.utils.env_file", - "canyonos_core.utils.env_file", - ): - try: - module = importlib.import_module(module_name) - except Exception: # noqa: BLE001 - try the other supported location - continue - if hasattr(module, "resolve_env_file"): - capabilities["env_file"] = True - break return capabilities From 226276b1c74fd00431ede3d171f71bf7278f8795 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Thu, 10 Sep 2026 16:23:35 -0700 Subject: [PATCH 2/3] Document project_id and cleanup_interval in manifest.md Both are real config keys global_controller.py reads off self.config, found by a coverage check being added in CAN-282's contract-tests work (#95): every top-level config key the controller reads must have a row in this table, the same class of gap that let otel.destinations go undocumented in this PR. - cleanup_interval: developer-set, defaults to 10 like poll_interval. - project_id: derived, not developer-set -- the controller generates and persists a UUID on first load when absent, so omitting it is not an error. --- .claude/skills/porting-to-canyonos/references/manifest.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/skills/porting-to-canyonos/references/manifest.md b/.claude/skills/porting-to-canyonos/references/manifest.md index a2520888..395fae82 100644 --- a/.claude/skills/porting-to-canyonos/references/manifest.md +++ b/.claude/skills/porting-to-canyonos/references/manifest.md @@ -45,8 +45,10 @@ column only, in one round, carrying these defaults. | `api_port` | developer | `8080` | | `redis_port`, `redis.host` / `.port` / `.db` | developer | `6379`, `localhost` / `6379` / `0` | | `poll_interval` | developer | `5` | +| `cleanup_interval` | developer | `10` | | `env_file` | developer — the file's location and whether it exists | `.env` when the survey found credential reads, else absent | | `otel.destinations` | derived for `provider: local` (see below) | the local dashboard's OTLP ingest | +| `project_id` | derived — generated once by the controller and written back into the config file | absent on first write; a generated UUID after | | `policy.yaml` | developer | absent | Three entries in that table are not free choices, and saying so is part of showing @@ -81,6 +83,11 @@ the config rather than asking about it: read [ec2.md](ec2.md#networking) before reusing this block on an entry with `provider: EC2`. +Omitting `project_id` is not the same as leaving it unset: the controller +generates a UUID on first load and appends `project_id: ""` to the +config file on disk so it survives reloads and restarts. Never invent one when +reviewing a candidate manifest -- absent means "not yet assigned", not "missing". + ## Configuration review Use the interaction implemented by `canyonos config` before writing From 2b52054054a32fa16a02c0a936400ac17bd57a21 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Thu, 10 Sep 2026 17:05:14 -0700 Subject: [PATCH 3/3] Add porting-skill contract tests and wire pytest into CI canyonos_core's runtime contract changes faster than the porting-to-canyonos skill's docs and validators track it (see 76b5a45, #83). Pin the skill's factual claims to executable assertions against real canyonos_core source instead of hand-maintained prose, and run them where they can actually block a PR: - tests/test_porting_skill_contract.py: behavior assertions for claims in manifest.md/runtime-contract.md, plus a coverage check that diffs every top-level config key canyonos_core reads off `self.config` against manifest.md's ownership table -- the same class of gap that let otel.destinations go undocumented. It found two more real gaps (project_id, cleanup_interval); those manifest.md rows are documented in #86 rather than here, since that's the PR already carrying this skill's doc fixes. - .github/workflows/ci.yml: actually run `pytest`. It never had before. - tests/conftest.py: compile the gRPC stubs global_controller.py imports at module load time, so importing it from a plain checkout doesn't require a prior `canyonos build`. This was blocking collection of 11 existing test files, which is almost certainly why pytest was never wired into CI. - tests/test_cli.py: three deploy tests globally monkeypatched `os.path.isfile` truthy, which also fooled resolve_env_file's unrelated platform-secrets check. Patch resolve_env_file directly instead. --- .../references/runtime-contract.md | 6 + .github/workflows/ci.yml | 9 +- tests/conftest.py | 36 +++++ tests/test_cli.py | 4 + tests/test_porting_skill_contract.py | 135 ++++++++++++++++++ 5 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_porting_skill_contract.py diff --git a/.claude/skills/porting-to-canyonos/references/runtime-contract.md b/.claude/skills/porting-to-canyonos/references/runtime-contract.md index 14eff262..56384707 100644 --- a/.claude/skills/porting-to-canyonos/references/runtime-contract.md +++ b/.claude/skills/porting-to-canyonos/references/runtime-contract.md @@ -15,6 +15,12 @@ Runtime-dependent behavior is expressed as capabilities; run `validate.py` against the target environment instead of inferring support from release history. +Every factual claim in this file and in `manifest.md` about how +`canyonos_core` behaves is pinned to an assertion in +`tests/test_porting_skill_contract.py`, which runs in CI against the real +`canyonos_core` source. When that suite fails, fix the doc or validator it +names before touching the assertion itself. + ## Contents - Artifact root and discovery diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54b9e9ae..a91b382d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,14 +29,17 @@ jobs: - name: "Run Ruff: Format Check" run: uvx ruff format --check . - - name: "Install dependencies for Ty" + - name: "Install dependencies" run: | - uv venv + uv venv uv pip install -e . - + - name: "Run Ty: Type Check" run: uvx ty check + - name: "Run tests" + run: uv run pytest -q + # If you want to run this locally, install act and run "act pull_request" # For fixing Ruff lint errors: uvx ruff check --fix . # For fixing Ruff formatting errors: uvx ruff format . \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..ee3a9f55 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +"""Compiles the gRPC stubs canyonos_core imports at module load time. + +`local_controler_pb2` / `local_controler_pb2_grpc` are never checked in -- +`canyonos build` generates them per deployment into `.car/grpc_stubs` (see +`canyonos_core/cli.py`). Importing `global_controller` or `local_controller` +from a plain source checkout needs the same modules importable, so compile +them once per test session into a scratch directory and put it on `sys.path` +before any test module imports canyonos_core's controller package. +""" + +import os +import subprocess +import sys +import tempfile + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PROTO_DIR = os.path.join(_REPO_ROOT, "canyonos_core", "controller", "proto") +_STUBS_DIR = tempfile.mkdtemp(prefix="canyonos-test-grpc-stubs-") + +for _proto_file in sorted(os.listdir(_PROTO_DIR)): + if not _proto_file.endswith(".proto"): + continue + subprocess.run( + [ + sys.executable, + "-m", + "grpc_tools.protoc", + f"-I{_PROTO_DIR}", + f"--python_out={_STUBS_DIR}", + f"--grpc_python_out={_STUBS_DIR}", + os.path.join(_PROTO_DIR, _proto_file), + ], + check=True, + ) + +sys.path.insert(0, _STUBS_DIR) diff --git a/tests/test_cli.py b/tests/test_cli.py index f07a39db..1b18753a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,6 +42,7 @@ def test_deploy_skips_ec2_preflight_for_local_config( with ( patch("canyonos_core.cli.os.path.isfile", return_value=True), patch("canyonos_core.cli._load_config", return_value=config), + patch("canyonos_core.cli.resolve_env_file", return_value=None), patch.dict( sys.modules, {"canyonos_core.controller.global_controller": controller_module} ), @@ -75,6 +76,7 @@ def test_deploy_runs_ec2_preflight_for_ec2_config( with ( patch("canyonos_core.cli.os.path.isfile", return_value=True), patch("canyonos_core.cli._load_config", return_value=config), + patch("canyonos_core.cli.resolve_env_file", return_value=None), patch.dict( sys.modules, {"canyonos_core.controller.global_controller": controller_module} ), @@ -101,6 +103,8 @@ def test_deploy_uses_car_when_present( "canyonos_core.cli.os.path.isfile", return_value=True ), patch( "canyonos_core.cli._load_config", return_value={"agents": []} + ), patch( + "canyonos_core.cli.resolve_env_file", return_value=None ), patch.dict( sys.modules, {"canyonos_core.controller.global_controller": controller_module} ): diff --git a/tests/test_porting_skill_contract.py b/tests/test_porting_skill_contract.py new file mode 100644 index 00000000..03a84642 --- /dev/null +++ b/tests/test_porting_skill_contract.py @@ -0,0 +1,135 @@ +"""Keeps `.claude/skills/porting-to-canyonos` honest against canyonos_core's +actual runtime behavior instead of a hand-maintained copy of it. + +Two different failure modes live here, and they need different tests: + +- `BehaviorContractTests`: a skill doc or validator claims canyonos_core + behaves a specific way. Each test re-derives that claim from real source + (not a mock) so a behavior change in canyonos_core turns the matching + claim red instead of quietly going stale. See the V030/V031 fix in + 76b5a45, where `env_file` injection and an `_install_step` were probed as + if they could vary when neither ever has. + +- `ConfigKeyCoverageTests`: canyonos_core can grow a new top-level config key + (a `self.config.get("...")` call) without anyone updating the skill's + manifest.md ownership table. That is exactly how `otel.destinations` + went undocumented -- this test diffs the two lists so it can't happen + silently again. + +When a test here fails, the fix is almost always in the skill doc or +validator it is pinned to (see each test's `skill:` comment), not in this +file. Only change the assertion itself once you've confirmed the old claim +is genuinely gone from canyonos_core, not merely inconvenient. +""" + +import inspect +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from canyonos_core.controller import global_controller +from canyonos_core.controller.utils import env_file as env_file_module + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SKILL_ROOT = os.path.join(REPO_ROOT, ".claude", "skills", "porting-to-canyonos") +MANIFEST_MD = os.path.join(SKILL_ROOT, "references", "manifest.md") + + +class BehaviorContractTests(unittest.TestCase): + def test_env_file_injection_is_unconditional(self): + # skill: references/manifest.md#ownership-of-configuration-keys (env_file row) + # resolve_env_file must run for every GlobalController, not behind a + # probed capability. + source = inspect.getsource(global_controller.GlobalController.__init__) + self.assertIn("resolve_env_file(self.config)", source) + + def test_no_editable_install_mechanism_exists(self): + # skill: references/manifest.md#per-image-requirements + # "pyproject.toml is installed only where the editable-install + # capability is available" -- that capability has never existed in + # canyonos_core. If this test fails, canyonos_core grew one: restore + # the editable_install probe in validate.py's probe_capabilities() + # instead of deleting this test. + self.assertFalse(hasattr(global_controller, "_install_step")) + + def test_otel_destinations_is_optional_and_silent_when_absent(self): + # skill: references/manifest.md#ownership-of-configuration-keys (otel.destinations row) + source = inspect.getsource(global_controller.GlobalController.__init__) + self.assertIn('self.config.get("otel", {})', source) + self.assertIsNone(global_controller.GlobalController._otel_destinations({})) + + def test_env_file_config_key_is_ignored_under_managed_secrets(self): + # skill: references/manifest.md (env_file row) -- a managed deployment's + # platform secrets file wins over a self-hosted env_file, it does not error. + source = inspect.getsource(env_file_module.resolve_env_file) + self.assertIn('config.get("env_file")', source) + + def test_project_id_is_generated_and_persisted_when_absent(self): + # skill: references/manifest.md#ownership-of-configuration-keys (project_id row) + source = inspect.getsource(global_controller.GlobalController._load_config) + self.assertIn("_assign_new_project_id", source) + + +class ConfigKeyCoverageTests(unittest.TestCase): + """skill: references/manifest.md#ownership-of-configuration-keys + + Every top-level key canyonos_core reads off the config dict must have a + row in manifest.md's ownership table. + """ + + CONTRACT_SURFACE = ( + os.path.join("canyonos_core", "controller", "global_controller.py"), + os.path.join("canyonos_core", "controller", "utils", "env_file.py"), + ) + + # Keys that are real and load-bearing but are not "one scalar choice" rows + # in the ownership table -- they're documented as their own section + # instead. Add to this set only with a comment saying where else the key + # is actually documented; it is an escape hatch, not a place to bury gaps. + STRUCTURAL_KEYS = frozenset( + { + "agents", # the whole manifest's subject; see "Agent declarations" + } + ) + + # (?